"
end
@@ -155,6 +159,7 @@ module RepositoriesHelper
end
str = replace_invalid_utf8(str)
end
+
private :to_utf8_internal
def replace_invalid_utf8(str)
@@ -162,8 +167,8 @@ module RepositoriesHelper
if str.respond_to?(:force_encoding)
str.force_encoding('UTF-8')
if !str.valid_encoding?
- str = str.encode('US-ASCII', invalid: :replace,
- undef: :replace, replace: '?').encode('UTF-8')
+ str = str.encode("US-ASCII", invalid: :replace,
+ undef: :replace, replace: '?').encode("UTF-8")
end
else
# removes invalid UTF8 sequences
@@ -175,36 +180,66 @@ module RepositoriesHelper
str
end
- def repository_field_tags(form, repository)
- method = repository.class.name.demodulize.underscore + '_field_tags'
- if repository.is_a?(Repository) &&
- respond_to?(method) && method != 'repository_field_tags'
- send(method, form, repository)
+ ##
+ # Retrieves all valid SCM vendors from the Manager
+ # and injects an already persisted repository for correctly
+ # displaying an existing repository.
+ def scm_options(repository = nil)
+ scms = OpenProject::Scm::Manager.enabled
+ vendor = repository.nil? ? nil : repository.vendor
+
+ ## Set selected vendor
+ if vendor && !repository.new_record?
+ scms[vendor] = vendor
end
+
+ # Remove repositories that were configured to have no
+ # available types left.
+ scms.reject! { |_, klass| klass.available_types.empty? }
+
+ scms = [default_selected_option] + scms.keys
+ options_for_select(scms, vendor)
end
- def scm_select_tag(repository)
- scm_options = [["--- #{l(:actionview_instancetag_blank_option)} ---", '']]
- Redmine::Scm::Base.configured.each do |scm|
- if Setting.enabled_scm.include?(scm) ||
- (repository && repository.class.name.demodulize == scm)
- scm_options << ["Repository::#{scm}".constantize.scm_name, scm]
- end
- end
- select_tag('repository_scm',
- options_for_select(scm_options, repository.class.name.demodulize),
- disabled: (repository && !repository.new_record?),
- onchange: remote_function(
- url: {
- controller: '/repositories',
- action: 'edit',
- id: @project
- },
- method: :get,
- with: 'Form.serialize(this.form)')
+ def default_selected_option
+ [
+ "--- #{l(:actionview_instancetag_blank_option)} ---",
+ '',
+ { disabled: true, selected: true }
+ ]
+ end
+
+ def vendor_name(repository)
+ repository.vendor.underscore
+ end
+
+ def scm_vendor_tag(repository)
+ select_tag('scm_vendor',
+ scm_options(repository),
+ class: 'form--select repositories--remote-select',
+ data: {
+ remote: true,
+ url: url_for(controller: '/repositories',
+ action: 'edit', project_id: @project.id),
+ },
+ disabled: (repository && !repository.new_record?)
)
end
+ def git_path_encoding_options(repository)
+ default = repository.new_record? ? 'UTF-8' : repository.path_encoding
+ options_for_select(Setting::ENCODINGS, default)
+ end
+
+ ##
+ # Determines whether the repository settings save button should be shown.
+ # By default, it is not shown when repository exists and is managed.
+ def show_settings_save_button?(repository)
+ @repository.nil? ||
+ @repository.new_record? ||
+ !@repository.managed?
+ end
+
def with_leading_slash(path)
path.to_s.starts_with?('/') ? path : "/#{path}"
end
@@ -212,74 +247,4 @@ module RepositoriesHelper
def without_leading_slash(path)
path.gsub(%r{\A/+}, '')
end
-
- def subversion_field_tags(form, repository)
- url = content_tag('div', class: 'form--field') {
- form.text_field(:url,
- size: 60,
- required: true,
- disabled: (repository && !repository.root_url.blank?)) +
- content_tag('div',
- 'file:///, http://, https://, svn://, svn+[tunnelscheme]://',
- class: 'form--field-instructions')
- }
-
- login = content_tag('div', class: 'form--field') {
- form.text_field(:login, size: 30)
- }
-
- pwd = content_tag('div', class: 'form--field') {
- form.password_field(:password,
- size: 30,
- name: 'ignore',
- value: ((repository.new_record? || repository.password.blank?) ? '' : ('x' * 15)),
- onfocus: "this.value=''; this.name='repository[password]';",
- onchange: "this.name='repository[password]';")
- }
-
- url + login + pwd
- end
-
- def git_field_tags(form, repository)
- url = content_tag('div', class: 'form--field -required') {
- form.text_field(:url,
- label: :label_git_path,
- size: 60,
- disabled: (repository && !repository.root_url.blank?)) +
- content_tag('div',
- l(:text_git_repo_example),
- class: 'form--field-instructions')
- }
-
- encoding = content_tag('div', class: 'form--field') {
- form.select(:path_encoding,
- [nil] + Setting::ENCODINGS,
- label: l(:label_path_encoding)) +
- content_tag('div',
- l(:text_default_encoding),
- class: 'form--field-instructions')
- }
-
- url + encoding
- end
-
- def filesystem_field_tags(form, repository)
- url = content_tag('div', class: 'form--field -required') {
- form.text_field(:url,
- label: :label_filesystem_path,
- size: 60,
- disabled: (repository && !repository.root_url.blank?))
- }
-
- encoding = content_tag('div', class: 'form--field') {
- form.select(:path_encoding,
- [nil] + Setting::ENCODINGS,
- label: l(:label_path_encoding)) +
- content_tag('div',
- l(:text_default_encoding),
- class: 'form--field-instructions')
- }
-
- url + encoding
- end
end
diff --git a/app/helpers/work_packages_helper.rb b/app/helpers/work_packages_helper.rb
index b3a48b53321..1401d906481 100644
--- a/app/helpers/work_packages_helper.rb
+++ b/app/helpers/work_packages_helper.rb
@@ -173,9 +173,9 @@ module WorkPackagesHelper
['start_date', 'due_date'].each do |date|
if changed_dates[date].nil? &&
- journal.changed_data[date] &&
- journal.changed_data[date].first
- changed_dates[date] = " (#{journal.changed_data[date].first})".html_safe
+ journal.details[date] &&
+ journal.details[date].first
+ changed_dates[date] = " (#{journal.details[date].first})".html_safe
end
end
end
diff --git a/app/mailers/user_mailer.rb b/app/mailers/user_mailer.rb
index 60d1b215089..96a5dff3820 100644
--- a/app/mailers/user_mailer.rb
+++ b/app/mailers/user_mailer.rb
@@ -47,8 +47,10 @@ class UserMailer < BaseMailer
end
end
- def work_package_added(user, work_package, author)
+ def work_package_added(user, journal, author)
+ work_package = journal.journable.reload
@issue = work_package # instance variable is used in the view
+ @journal = journal
set_work_package_headers(work_package)
diff --git a/app/models/activity/work_package_activity_provider.rb b/app/models/activity/work_package_activity_provider.rb
index 73453a56921..3d0c283681a 100644
--- a/app/models/activity/work_package_activity_provider.rb
+++ b/app/models/activity/work_package_activity_provider.rb
@@ -65,7 +65,7 @@ class Activity::WorkPackageActivityProvider < Activity::BaseActivityProvider
state = ''
journal = Journal.find(event['event_id'])
- if journal.changed_data.empty? && !journal.initial?
+ if journal.details.empty? && !journal.initial?
state = '-note'
else
state = ActiveRecord::ConnectionAdapters::Column.value_to_boolean(event['status_closed']) ? '-closed' : '-edit'
diff --git a/app/models/auth_source.rb b/app/models/auth_source.rb
index 195fd7b0f17..cdaab434fa0 100644
--- a/app/models/auth_source.rb
+++ b/app/models/auth_source.rb
@@ -40,7 +40,9 @@ class AuthSource < ActiveRecord::Base
def authenticate(_login, _password)
end
+ # implemented by a subclass, should raise when no connection is possible and not raise on success
def test_connection
+ raise I18n.t('auth_source.using_abstract_auth_source')
end
def auth_method_name
diff --git a/app/models/comment_observer.rb b/app/models/comment_observer.rb
index 8319457331a..09254f297de 100644
--- a/app/models/comment_observer.rb
+++ b/app/models/comment_observer.rb
@@ -29,13 +29,12 @@
class CommentObserver < ActiveRecord::Observer
def after_create(comment)
- return unless Notifier.notify?(:news_comment_added)
+ return unless Setting.notified_events.include?('news_comment_added')
if comment.commented.is_a?(News)
news = comment.commented
recipients = news.recipients + news.watcher_recipients
- users = User.find_all_by_mails(recipients)
- users.each do |user|
+ recipients.uniq.each do |user|
UserMailer.news_comment_added(user, comment, User.current).deliver
end
end
diff --git a/app/models/journal.rb b/app/models/journal.rb
index 3cfd9037608..10e690491cb 100644
--- a/app/models/journal.rb
+++ b/app/models/journal.rb
@@ -30,6 +30,7 @@
class Journal < ActiveRecord::Base
self.table_name = 'journals'
+ include JournalChanges
include JournalFormatter
include FormatHooks
@@ -99,6 +100,7 @@ class Journal < ActiveRecord::Base
get_changes
end
+ # TODO Evaluate whether this can be removed without disturbing any migrations
alias_method :changed_data, :details
def new_value_for(prop)
@@ -134,57 +136,6 @@ class Journal < ActiveRecord::Base
end
end
- def get_changes
- return {} if data.nil?
-
- if @changes.nil?
- @changes = HashWithIndifferentAccess.new
-
- if predecessor.nil?
- @changes = data.journaled_attributes.select { |_, v| !v.nil? }
- .inject({}) { |h, (k, v)| h[k] = [nil, v]; h }
- else
- normalized_data = JournalManager.normalize_newlines(data.journaled_attributes)
- normalized_predecessor_data = JournalManager.normalize_newlines(predecessor.data.journaled_attributes)
-
- normalized_data.select { |k, v|
- # we dont record changes for changes from nil to empty strings and vice versa
- pred = normalized_predecessor_data[k]
- v != pred && (v.present? || pred.present?)
- }.each do |k, v|
- @changes[k] = [normalized_predecessor_data[k], v]
- end
- end
-
- @changes.merge!(get_association_changes predecessor, 'attachable', 'attachments', :attachment_id, :filename)
- @changes.merge!(get_association_changes predecessor, 'customizable', 'custom_fields', :custom_field_id, :value)
- end
-
- @changes
- end
-
- def get_association_changes(predecessor, journal_association, association, key, value)
- changes = {}
- journal_assoc_name = "#{journal_association}_journals"
-
- if predecessor.nil?
- send(journal_assoc_name).each_with_object(changes) { |a, h| h["#{association}_#{a.send(key)}"] = [nil, a.send(value)] }
- else
- current = send(journal_assoc_name).map(&:attributes)
- predecessor_attachable_journals = predecessor.send(journal_assoc_name).map(&:attributes)
-
- merged_journals = JournalManager.merge_reference_journals_by_id current,
- predecessor_attachable_journals,
- key.to_s
-
- changes.merge! JournalManager.added_references(merged_journals, association, value.to_s)
- changes.merge! JournalManager.removed_references(merged_journals, association, value.to_s)
- changes.merge! JournalManager.changed_references(merged_journals, association, value.to_s)
- end
-
- changes
- end
-
def predecessor
@predecessor ||= self.class
.where(journable_type: journable_type, journable_id: journable_id)
diff --git a/app/models/journal/aggregated_journal.rb b/app/models/journal/aggregated_journal.rb
index 4e529254afd..7ab0349e0a3 100644
--- a/app/models/journal/aggregated_journal.rb
+++ b/app/models/journal/aggregated_journal.rb
@@ -41,13 +41,35 @@
# be dropped
class Journal::AggregatedJournal
class << self
- def aggregated_journals(journable: nil)
- query_aggregated_journals(journable: journable).map { |journal|
+ # Returns the aggregated journal that contains the specified (vanilla/pure) journal.
+ def for_journal(pure_journal)
+ raw = Journal::AggregatedJournal.query_aggregated_journals(journable: pure_journal.journable)
+ .where("#{version_projection} >= ?", pure_journal.version)
+ .first
+
+ raw ? Journal::AggregatedJournal.new(raw) : nil
+ end
+
+ def with_notes_id(notes_id)
+ raw_journal = query_aggregated_journals
+ .where("#{table_name}.id = ?", notes_id)
+ .first
+
+ raw_journal ? Journal::AggregatedJournal.new(raw_journal) : nil
+ end
+
+ ##
+ # The +journable+ parameter allows to filter for aggregated journals of a given journable.
+ #
+ # The +until_version+ parameter can be used in conjunction with the +journable+ parameter
+ # to see the aggregated journals as if no versions were known after the specified version.
+ def aggregated_journals(journable: nil, until_version: nil)
+ query_aggregated_journals(journable: journable, until_version: until_version).map { |journal|
Journal::AggregatedJournal.new(journal)
}
end
- def query_aggregated_journals(journable: nil)
+ def query_aggregated_journals(journable: nil, until_version: nil)
# Using the roughly aggregated groups from :sql_rough_group we need to merge journals
# where an entry with empty notes follows an entry containing notes, so that the notes
# from the main entry are taken, while the remaining information is taken from the
@@ -60,21 +82,58 @@ class Journal::AggregatedJournal
# that our own row (master) would not already have been merged by its predecessor. If it is
# (that means if we can find a valid predecessor), we drop our current row, because it will
# already be present (in a merged form) in the row of our predecessor.
- Journal.from("(#{sql_rough_group(journable, 1)}) #{table_name}")
- .joins("LEFT OUTER JOIN (#{sql_rough_group(journable, 2)}) addition
+ Journal.from("(#{sql_rough_group(journable, until_version, 1)}) #{table_name}")
+ .joins("LEFT OUTER JOIN (#{sql_rough_group(journable, until_version, 2)}) addition
ON #{sql_on_groups_belong_condition(table_name, 'addition')}")
- .joins("LEFT OUTER JOIN (#{sql_rough_group(journable, 3)}) predecessor
+ .joins("LEFT OUTER JOIN (#{sql_rough_group(journable, until_version, 3)}) predecessor
ON #{sql_on_groups_belong_condition('predecessor', table_name)}")
.where('predecessor.id IS NULL')
+ .order("COALESCE(addition.created_at, #{table_name}.created_at) ASC")
.select("#{table_name}.journable_id,
#{table_name}.journable_type,
#{table_name}.user_id,
#{table_name}.notes,
#{table_name}.id \"notes_id\",
+ #{table_name}.version \"notes_version\",
#{table_name}.activity_type,
COALESCE(addition.created_at, #{table_name}.created_at) \"created_at\",
COALESCE(addition.id, #{table_name}.id) \"id\",
- COALESCE(addition.version, #{table_name}.version) \"version\"")
+ #{version_projection} \"version\"")
+ end
+
+ # Returns whether "notification-hiding" should be assumed for the given journal pair.
+ # This leads to an aggregated journal effectively blocking notifications of an earlier journal,
+ # because it "steals" the addition from its predecessor. See the specs section under
+ # "mail suppressing aggregation" (for EnqueueWorkPackageNotificationJob) for more details
+ def hides_notifications?(successor, predecessor)
+ return false unless successor && predecessor
+
+ timeout = Setting.journal_aggregation_time_minutes.to_i.minutes
+
+ if successor.journable_type != predecessor.journable_type ||
+ successor.journable_id != predecessor.journable_id ||
+ successor.user_id != predecessor.user_id ||
+ (successor.created_at - predecessor.created_at) <= timeout
+ return false
+ end
+
+ # imaginary state in which the successor never existed
+ # if this makes the predecessor disappear, the successor must have taken journals
+ # from it (that now became part of the predecessor again).
+ !Journal::AggregatedJournal
+ .query_aggregated_journals(
+ journable: successor.journable,
+ until_version: successor.version - 1)
+ .where("#{version_projection} = #{predecessor.version}")
+ .exists?
+ end
+
+ def table_name
+ Journal.table_name
+ end
+
+ def version_projection
+ "COALESCE(addition.version, #{table_name}.version)"
end
private
@@ -88,21 +147,31 @@ class Journal::AggregatedJournal
# To be able to self-join results of this statement, we add an additional column called
# "group_number" to the result. This allows to compare a group resulting from this query with
# its predecessor and successor.
- def sql_rough_group(journable, uid)
+ def sql_rough_group(journable, until_version, uid)
+ if until_version && !journable
+ raise 'need to provide a journable, when specifying a version limit'
+ end
+
sql = "SELECT predecessor.*, #{sql_group_counter(uid)} AS group_number
FROM #{sql_rough_group_from_clause(uid)}
LEFT OUTER JOIN journals successor
ON predecessor.version + 1 = successor.version AND
predecessor.journable_type = successor.journable_type AND
predecessor.journable_id = successor.journable_id
+ #{until_version ? " AND successor.version <= #{until_version}" : ''}
WHERE (predecessor.user_id != successor.user_id OR
(predecessor.notes != '' AND predecessor.notes IS NOT NULL) OR
#{sql_beyond_aggregation_time?('predecessor', 'successor')} OR
successor.id IS NULL)"
if journable
+ raise 'journable has no id' if journable.id.nil?
sql += " AND predecessor.journable_type = '#{journable.class.name}' AND
predecessor.journable_id = #{journable.id}"
+
+ if until_version
+ sql += " AND predecessor.version <= #{until_version}"
+ end
end
sql
@@ -117,7 +186,7 @@ class Journal::AggregatedJournal
group_counter = mysql_group_count_variable(uid)
"(#{group_counter} := #{group_counter} + 1)"
else
- 'row_number() OVER ()'
+ 'row_number() OVER (ORDER BY predecessor.version ASC)'
end
end
@@ -153,7 +222,12 @@ class Journal::AggregatedJournal
# to be considered for aggregation. This takes the current instance settings for temporal
# proximity into account.
def sql_beyond_aggregation_time?(predecessor, successor)
- aggregation_time_seconds = Setting.journal_aggregation_time_minutes.to_i * 60
+ aggregation_time_seconds = Setting.journal_aggregation_time_minutes.to_i.minutes
+ if aggregation_time_seconds == 0
+ # if aggregation is disabled, we consider everything to be beyond aggregation time
+ # even if creation dates are exactly equal
+ return '(true = true)'
+ end
if OpenProject::Database.mysql?
difference = "TIMESTAMPDIFF(second, #{predecessor}.created_at, #{successor}.created_at)"
@@ -165,29 +239,44 @@ class Journal::AggregatedJournal
"(#{difference} > #{threshold})"
end
-
- def table_name
- Journal.table_name
- end
end
+ include JournalChanges
+ include JournalFormatter
+ include Redmine::Acts::Journalized::FormatHooks
+
+ register_journal_formatter :diff, OpenProject::JournalFormatter::Diff
+ register_journal_formatter :attachment, OpenProject::JournalFormatter::Attachment
+ register_journal_formatter :custom_field, OpenProject::JournalFormatter::CustomField
+
+ alias_method :details, :get_changes
+
delegate :journable_type,
:journable_id,
:journable,
:user_id,
:user,
:notes,
+ :notes?,
:activity_type,
:created_at,
:id,
:version,
:attributes,
+ :attachable_journals,
+ :customizable_journals,
+ :editable_by?,
to: :journal
def initialize(journal)
@journal = journal
end
+ # returns an instance of this class that is reloaded from the database
+ def reloaded
+ self.class.with_notes_id(notes_id)
+ end
+
def user
@user ||= User.find(user_id)
end
@@ -195,8 +284,9 @@ class Journal::AggregatedJournal
def predecessor
unless defined? @predecessor
raw_journal = self.class.query_aggregated_journals(journable: journable)
- .where("#{Journal.table_name}.version < ?", version)
- .order("#{Journal.table_name}.version DESC")
+ .where("#{self.class.version_projection} < ?", version)
+ .except(:order)
+ .order("#{self.class.version_projection} DESC")
.first
@predecessor = raw_journal ? Journal::AggregatedJournal.new(raw_journal) : nil
@@ -205,10 +295,28 @@ class Journal::AggregatedJournal
@predecessor
end
+ def successor
+ unless defined? @successor
+ raw_journal = self.class.query_aggregated_journals(journable: journable)
+ .where("#{self.class.version_projection} > ?", version)
+ .except(:order)
+ .order("#{self.class.version_projection} ASC")
+ .first
+
+ @successor = raw_journal ? Journal::AggregatedJournal.new(raw_journal) : nil
+ end
+
+ @successor
+ end
+
def initial?
predecessor.nil?
end
+ def data
+ @data ||= "Journal::#{journable_type}Journal".constantize.find_by_journal_id(id)
+ end
+
# ARs automagic addition of dynamic columns (those not present in the physical table) seems
# not to work with PostgreSQL and simply return a string for unknown columns.
# Thus we need to ensure manually that this column is correctly casted.
@@ -216,6 +324,10 @@ class Journal::AggregatedJournal
ActiveRecord::ConnectionAdapters::Column.value_to_integer(journal.notes_id)
end
+ def notes_version
+ ActiveRecord::ConnectionAdapters::Column.value_to_integer(journal.notes_version)
+ end
+
private
attr_reader :journal
diff --git a/app/models/journal_manager.rb b/app/models/journal_manager.rb
index 2fbccdfc9a4..ca9c372e5dc 100644
--- a/app/models/journal_manager.rb
+++ b/app/models/journal_manager.rb
@@ -28,6 +28,12 @@
#++
class JournalManager
+ class << self
+ attr_accessor :send_notification
+ end
+
+ self.send_notification = true
+
def self.is_journalized?(obj)
not obj.nil? and obj.respond_to? :journals
end
@@ -85,31 +91,45 @@ class JournalManager
# This would lead to false change information, otherwise.
# We need to be careful though, because we want to accept false (and false.blank? == true)
def self.remove_empty_associations(associations, value)
- associations.reject { |h| h.has_key?(value) && h[value].blank? && h[value] != false }
+ associations.reject { |association|
+ association.has_key?(value) &&
+ association[value].blank? &&
+ association[value] != false
+ }
end
- def self.merge_reference_journals_by_id(current, predecessor, key)
- all_attachable_journal_ids = current.map { |j| j[key] } | predecessor.map { |j| j[key] }
+ def self.merge_reference_journals_by_id(new_journals, old_journals, id_key)
+ all_associated_journal_ids = new_journals.map { |j| j[id_key] } |
+ old_journals.map { |j| j[id_key] }
- all_attachable_journal_ids.each_with_object({}) { |i, h|
- h[i] = [predecessor.detect { |j| j[key] == i },
- current.detect { |j| j[key] == i }]
+ all_associated_journal_ids.each_with_object({}) { |id, result|
+ result[id] = [old_journals.detect { |j| j[id_key] == id },
+ new_journals.detect { |j| j[id_key] == id }]
}
end
def self.added_references(merged_references, key, value)
- merged_references.select { |_, v| v[0].nil? and not v[1].nil? }
- .each_with_object({}) { |k, h| h["#{key}_#{k[0]}"] = [nil, k[1][1][value]] }
+ merged_references.select { |_, (old_attributes, new_attributes)|
+ old_attributes.nil? && !new_attributes.nil?
+ }.each_with_object({}) { |(id, (_, new_attributes)), result|
+ result["#{key}_#{id}"] = [nil, new_attributes[value]]
+ }
end
def self.removed_references(merged_references, key, value)
- merged_references.select { |_, v| not v[0].nil? and v[1].nil? }
- .each_with_object({}) { |k, h| h["#{key}_#{k[0]}"] = [k[1][0][value], nil] }
+ merged_references.select { |_, (old_attributes, new_attributes)|
+ !old_attributes.nil? && new_attributes.nil?
+ }.each_with_object({}) { |(id, (old_attributes, _)), result|
+ result["#{key}_#{id}"] = [old_attributes[value], nil]
+ }
end
def self.changed_references(merged_references, key, value)
- merged_references.select { |_, v| not v[0].nil? and not v[1].nil? and v[0][value] != v[1][value] }
- .each_with_object({}) { |k, h| h["#{key}_#{k[0]}"] = [k[1][0][value], k[1][1][value]] }
+ merged_references.select { |_, (old_attributes, new_attributes)|
+ !old_attributes.nil? && !new_attributes.nil? && old_attributes[value] != new_attributes[value]
+ }.each_with_object({}) { |(id, (old_attributes, new_attributes)), result|
+ result["#{key}_#{id}"] = [old_attributes[value], new_attributes[value]]
+ }
end
def self.recreate_initial_journal(type, journal, changed_data)
@@ -136,7 +156,7 @@ class JournalManager
journable_type: journal_class_name(journable.class),
version: version,
activity_type: journable.send(:activity_type),
- changed_data: journable.attributes.symbolize_keys }
+ details: journable.attributes.symbolize_keys }
create_journal journable, journal_attributes, user, notes
end
@@ -146,17 +166,19 @@ class JournalManager
type = base_class(journable.class)
extended_journal_attributes = journal_attributes.merge(journable_type: type.to_s)
.merge(notes: notes)
- .except(:changed_data)
+ .except(:details)
.except(:id)
unless extended_journal_attributes.has_key? :user_id
extended_journal_attributes[:user_id] = user.id
end
- journal_attributes[:changed_data] = normalize_newlines(journal_attributes[:changed_data])
+ journal_attributes[:details] = normalize_newlines(journal_attributes[:details])
journal = journable.journals.build extended_journal_attributes
- journal.data = create_journal_data journal.id, type, valid_journal_attributes(type, journal_attributes[:changed_data])
+ journal.data = create_journal_data journal.id,
+ type,
+ valid_journal_attributes(type, journal_attributes[:details])
create_association_data journable, journal
@@ -253,4 +275,8 @@ class JournalManager
journal.customizable_journals.build custom_field_id: cv.custom_field_id, value: cv.value
end
end
+
+ def self.reset_notification
+ @send_notification = true
+ end
end
diff --git a/app/models/journal_notification_mailer.rb b/app/models/journal_notification_mailer.rb
new file mode 100644
index 00000000000..4a28cae6820
--- /dev/null
+++ b/app/models/journal_notification_mailer.rb
@@ -0,0 +1,86 @@
+#-- encoding: UTF-8
+#-- copyright
+# OpenProject is a project management system.
+# Copyright (C) 2012-2015 the OpenProject Foundation (OPF)
+#
+# This program is free software; you can redistribute it and/or
+# modify it under the terms of the GNU General Public License version 3.
+#
+# OpenProject is a fork of ChiliProject, which is a fork of Redmine. The copyright follows:
+# Copyright (C) 2006-2013 Jean-Philippe Lang
+# Copyright (C) 2010-2013 the ChiliProject Team
+#
+# This program is free software; you can redistribute it and/or
+# modify it under the terms of the GNU General Public License
+# as published by the Free Software Foundation; either version 2
+# of the License, or (at your option) any later version.
+#
+# This program is distributed in the hope that it will be useful,
+# but WITHOUT ANY WARRANTY; without even the implied warranty of
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+# GNU General Public License for more details.
+#
+# You should have received a copy of the GNU General Public License
+# along with this program; if not, write to the Free Software
+# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
+#
+# See doc/COPYRIGHT.rdoc for more details.
+#++
+
+class JournalNotificationMailer
+ class << self
+ def distinguish_journals(journal, send_notification)
+ if send_notification
+ if journal.journable_type == 'WorkPackage'
+ handle_work_package_journal(journal)
+ end
+ end
+ end
+
+ def handle_work_package_journal(journal)
+ return nil unless send_notification? journal
+
+ aggregated = find_aggregated_journal_for(journal)
+
+ # Send the notification on behalf of the predecessor in case it could not send it on its own
+ if Journal::AggregatedJournal.hides_notifications?(aggregated, aggregated.predecessor)
+ job = DeliverWorkPackageNotificationJob.new(aggregated.predecessor.id, User.current.id)
+ Delayed::Job.enqueue job
+ end
+
+ job = EnqueueWorkPackageNotificationJob.new(journal.id, User.current.id)
+ Delayed::Job.enqueue job, run_at: delivery_time
+ end
+
+ def send_notification?(journal)
+ (Setting.notified_events.include?('work_package_added') && journal.initial?) ||
+ (Setting.notified_events.include?('work_package_updated') && !journal.initial?) ||
+ notify_for_notes?(journal) ||
+ notify_for_status?(journal) ||
+ notify_for_priority(journal)
+ end
+
+ def notify_for_notes?(journal)
+ Setting.notified_events.include?('work_package_note_added') && journal.notes.present?
+ end
+
+ def notify_for_status?(journal)
+ Setting.notified_events.include?('status_updated') &&
+ journal.details.has_key?(:status_id)
+ end
+
+ def notify_for_priority(journal)
+ Setting.notified_events.include?('work_package_priority_updated') &&
+ journal.details.has_key?(:priority_id)
+ end
+
+ def delivery_time
+ Setting.journal_aggregation_time_minutes.to_i.minutes.from_now
+ end
+
+ def find_aggregated_journal_for(raw_journal)
+ wp_journals = Journal::AggregatedJournal.aggregated_journals(journable: raw_journal.journable)
+ wp_journals.detect { |journal| journal.version == raw_journal.version }
+ end
+ end
+end
diff --git a/app/models/journal_observer.rb b/app/models/journal_observer.rb
deleted file mode 100644
index 7c38b0be018..00000000000
--- a/app/models/journal_observer.rb
+++ /dev/null
@@ -1,67 +0,0 @@
-#-- encoding: UTF-8
-#-- copyright
-# OpenProject is a project management system.
-# Copyright (C) 2012-2015 the OpenProject Foundation (OPF)
-#
-# This program is free software; you can redistribute it and/or
-# modify it under the terms of the GNU General Public License version 3.
-#
-# OpenProject is a fork of ChiliProject, which is a fork of Redmine. The copyright follows:
-# Copyright (C) 2006-2013 Jean-Philippe Lang
-# Copyright (C) 2010-2013 the ChiliProject Team
-#
-# This program is free software; you can redistribute it and/or
-# modify it under the terms of the GNU General Public License
-# as published by the Free Software Foundation; either version 2
-# of the License, or (at your option) any later version.
-#
-# This program is distributed in the hope that it will be useful,
-# but WITHOUT ANY WARRANTY; without even the implied warranty of
-# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
-# GNU General Public License for more details.
-#
-# You should have received a copy of the GNU General Public License
-# along with this program; if not, write to the Free Software
-# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
-#
-# See doc/COPYRIGHT.rdoc for more details.
-#++
-
-class JournalObserver < ActiveRecord::Observer
- attr_accessor :send_notification
-
- def after_create(journal)
- if journal.journable_type == 'WorkPackage' and !journal.initial? and send_notification
- after_create_issue_journal(journal)
- end
- clear_notification
- end
-
- def after_create_issue_journal(journal)
- if Setting.notified_events.include?('work_package_updated') ||
- (Setting.notified_events.include?('work_package_note_added') && journal.notes.present?) ||
- (Setting.notified_events.include?('status_updated') && journal.changed_data.has_key?(:status_id)) ||
- (Setting.notified_events.include?('work_package_priority_updated') && journal.changed_data.has_key?(:priority_id))
- issue = journal.journable
- recipients = issue.recipients + issue.watcher_recipients
- users = User.find_all_by_mails(recipients.uniq)
- users.each do |user|
- job = DeliverWorkPackageUpdatedJob.new(user.id, journal.id, User.current.id)
- Delayed::Job.enqueue job
- end
- end
- end
-
- # Wrap send_notification so it defaults to true, when it's nil
- def send_notification
- return true if @send_notification.nil?
- @send_notification
- end
-
- private
-
- # Need to clear the notification setting after each usage otherwise it might be cached
- def clear_notification
- @send_notification = true
- end
-end
diff --git a/app/models/ldap_auth_source.rb b/app/models/ldap_auth_source.rb
index 9fbdd6111f6..9ff046cef36 100644
--- a/app/models/ldap_auth_source.rb
+++ b/app/models/ldap_auth_source.rb
@@ -53,10 +53,11 @@ class LdapAuthSource < AuthSource
# test the connection to the LDAP
def test_connection
- ldap_con = initialize_ldap_con(account, account_password)
- ldap_con.open {}
+ unless authenticate_dn(account, account_password)
+ raise I18n.t('auth_source.ldap_error', error_message: I18n.t('auth_source.ldap_auth_failed'))
+ end
rescue Net::LDAP::LdapError => text
- raise 'LdapError: ' + text.to_s
+ raise I18n.t('auth_source.ldap_error', error_message: text.to_s)
end
def auth_method_name
diff --git a/app/models/legacy_journal.rb b/app/models/legacy_journal.rb
index ca6d665123d..b192856108c 100644
--- a/app/models/legacy_journal.rb
+++ b/app/models/legacy_journal.rb
@@ -115,6 +115,7 @@ class LegacyJournal < ActiveRecord::Base
attributes['changed_data'] || {}
end
+ # TODO Evaluate whether this can be removed without disturbing any migrations
alias_method :changed_data, :details
def new_value_for(prop)
diff --git a/app/models/message_observer.rb b/app/models/message_observer.rb
index 40621f2d37e..c3eb4311b65 100644
--- a/app/models/message_observer.rb
+++ b/app/models/message_observer.rb
@@ -33,8 +33,7 @@ class MessageObserver < ActiveRecord::Observer
recipients = message.recipients
recipients += message.root.watcher_recipients
recipients += message.board.watcher_recipients
- users = User.find_all_by_mails(recipients.uniq)
- users.each do |user|
+ recipients.uniq.each do |user|
UserMailer.message_posted(user, message, User.current).deliver
end
end
diff --git a/app/models/news_observer.rb b/app/models/news_observer.rb
index fac62827e4d..8499184eed5 100644
--- a/app/models/news_observer.rb
+++ b/app/models/news_observer.rb
@@ -30,8 +30,7 @@
class NewsObserver < ActiveRecord::Observer
def after_create(news)
if Setting.notified_events.include?('news_added')
- users = User.find_all_by_mails(news.recipients)
- users.each do |user|
+ news.recipients.uniq.each do |user|
UserMailer.news_added(user, news, User.current).deliver
end
end
diff --git a/app/models/planning_element_type_color.rb b/app/models/planning_element_type_color.rb
index 6af3668789d..c68e3d11482 100644
--- a/app/models/planning_element_type_color.rb
+++ b/app/models/planning_element_type_color.rb
@@ -46,25 +46,6 @@ class PlanningElementTypeColor < ActiveRecord::Base
validates_length_of :name, maximum: 255, unless: lambda { |e| e.name.blank? }
validates_format_of :hexcode, with: /\A#[0-9A-F]{6}\z/, unless: lambda { |e| e.hexcode.blank? }
- def self.colors
- [
- find_or_initialize_by(name: 'Black', hexcode: '#000000'),
- find_or_initialize_by(name: 'White', hexcode: '#FFFFFF'),
- find_or_initialize_by(name: 'Blue', hexcode: '#3399CC'),
- find_or_initialize_by(name: 'Mint', hexcode: '#66CCCC'),
- find_or_initialize_by(name: 'Lime', hexcode: '#66CC99'),
- find_or_initialize_by(name: 'Green-neon', hexcode: '#00CC33'),
- find_or_initialize_by(name: 'Green', hexcode: '#339933'),
- find_or_initialize_by(name: 'Orange', hexcode: '#FFCC00'),
- find_or_initialize_by(name: 'Red', hexcode: '#CC3333'),
- find_or_initialize_by(name: 'Red-bright', hexcode: '#FF3300'),
- find_or_initialize_by(name: 'Yellow', hexcode: '#FFFF00'),
- find_or_initialize_by(name: 'Purple', hexcode: '#CC0066'),
- find_or_initialize_by(name: 'Grey-dark', hexcode: '#666666'),
- find_or_initialize_by(name: 'Grey-light', hexcode: '#DDDDDD')
- ]
- end
-
def text_hexcode
# 0.63 - Optimal threshold to switch between white and black text color
# determined by intensive user tests and expensive research
diff --git a/app/models/project.rb b/app/models/project.rb
index aa8936524d6..d253e30be23 100644
--- a/app/models/project.rb
+++ b/app/models/project.rb
@@ -108,6 +108,9 @@ class Project < ActiveRecord::Base
author: nil,
datetime: :created_on
+ acts_as_countable :required_project_storage,
+ countable: [:work_packages, :repository]
+
attr_protected :status
validates_presence_of :name, :identifier
@@ -632,9 +635,9 @@ class Project < ActiveRecord::Base
possible_responsible_members.map(&:principal).compact.sort
end
- # Returns the mail adresses of users that should be always notified on project events
+ # Returns users that should be always notified on project events
def recipients
- notified_users.map(&:mail)
+ notified_users
end
# Returns the users that should be notified on project events
@@ -943,6 +946,24 @@ class Project < ActiveRecord::Base
update_attribute :status, STATUS_ARCHIVED
end
+ def required_storage
+ Rails.cache.fetch("project##{id}/project_storage",
+ expires_in: self.class.project_storage_expires_in) do
+ count_for(:required_project_storage).first
+ end
+ end
+
+ def self.total_projects_size
+ Rails.cache.fetch('projects/total_projects_size',
+ expires_in: project_storage_expires_in) do
+ Project.all.map { |p| p.required_storage[:total] }.sum
+ end
+ end
+
+ def self.project_storage_expires_in
+ Setting.project_storage_cache_minutes.to_i.minutes
+ end
+
protected
def self.possible_principles_condition
diff --git a/app/models/queries/work_packages/available_filter_options.rb b/app/models/queries/work_packages/available_filter_options.rb
index 6f77798c436..82cf6f3cb48 100644
--- a/app/models/queries/work_packages/available_filter_options.rb
+++ b/app/models/queries/work_packages/available_filter_options.rb
@@ -99,7 +99,7 @@ module Queries::WorkPackages::AvailableFilterOptions
@available_work_package_filters = {
status_id: { type: :list_status, order: 1, values: Status.all.map { |s| [s.name, s.id.to_s] } },
type_id: { type: :list, order: 2, values: types.map { |s| [s.name, s.id.to_s] } },
- priority_id: { type: :list, order: 3, values: IssuePriority.all.map { |s| [s.name, s.id.to_s] } },
+ priority_id: { type: :list, order: 3, values: IssuePriority.active.map { |s| [s.name, s.id.to_s] } },
subject: { type: :text, order: 8 },
created_at: { type: :date_past, order: 9 },
updated_at: { type: :date_past, order: 10 },
diff --git a/app/models/repository.rb b/app/models/repository.rb
index 847c0343d67..ace6e36de9f 100644
--- a/app/models/repository.rb
+++ b/app/models/repository.rb
@@ -29,6 +29,7 @@
class Repository < ActiveRecord::Base
include Redmine::Ciphering
+ include OpenProject::Scm::ManageableRepository
belongs_to :project
has_many :changesets, -> {
@@ -37,6 +38,10 @@ class Repository < ActiveRecord::Base
before_save :sanitize_urls
+ # Managed repository lifetime
+ after_create :create_managed_repository, if: Proc.new { |repo| repo.managed? }
+ after_destroy :delete_managed_repository, if: Proc.new { |repo| repo.managed? }
+
# Raw SQL to delete changesets and changes in the database
# has_many :changesets, :dependent => :destroy is too slow for big repositories
before_destroy :clear_changesets
@@ -46,6 +51,10 @@ class Repository < ActiveRecord::Base
validates_length_of :password, maximum: 255, allow_nil: true
validate :validate_enabled_scm, on: :create
+ acts_as_countable :required_project_storage,
+ label: 'label_repository',
+ countable: :required_disk_storage
+
def changes
Change.where(changeset_id: changesets).joins(:changeset)
end
@@ -80,12 +89,32 @@ class Repository < ActiveRecord::Base
def scm
@scm ||= scm_adapter.new(url, root_url,
login, password, path_encoding)
- update_attribute(:root_url, @scm.root_url) if root_url.blank?
+
+ # override the adapter's root url with the full url
+ # if none other was set.
+ unless @scm.root_url.present?
+ @scm.root_url = root_url.presence || url
+ end
+
@scm
end
- def scm_name
- self.class.scm_name
+ def self.scm_config
+ scm_adapter_class.config
+ end
+
+ def self.available_types
+ supported_types - disabled_types
+ end
+
+ ##
+ # Retrieves the :disabled_types setting from `configuration.yml
+ def self.disabled_types
+ scm_config[:disabled_types] || []
+ end
+
+ def vendor
+ self.class.vendor
end
def supports_cat?
@@ -148,6 +177,19 @@ class Repository < ActiveRecord::Base
path
end
+ def required_disk_storage
+ if scm.storage_available?
+ oldest_cachable_time = Setting.repository_storage_cache_minutes.to_i.minutes.ago
+ if storage_updated_at.nil? ||
+ storage_updated_at < oldest_cachable_time
+
+ Delayed::Job.enqueue ::Scm::StorageUpdaterJob.new(self)
+ end
+
+ required_storage_bytes
+ end
+ end
+
# Finds and returns a revision with a number or the beginning of a hash
def find_changeset_by_name(name)
name = name.to_s
@@ -240,7 +282,7 @@ class Repository < ActiveRecord::Base
if project.repository
begin
project.repository.fetch_changesets
- rescue Redmine::Scm::Adapters::CommandFailed => e
+ rescue OpenProject::Scm::Exceptions::CommandFailed => e
logger.error "scm: error during fetching changesets: #{e.message}"
end
end
@@ -252,61 +294,83 @@ class Repository < ActiveRecord::Base
all.each(&:scan_changesets_for_work_package_ids)
end
- def self.scm_name
- 'Abstract'
+
+ ##
+ # Builds a model instance of type +Repository::#{vendor}+ with the given parameters.
+ #
+ # @param [Project] project The project this repository belongs to.
+ # @param [String] vendor The SCM vendor name (e.g., Git, Subversion)
+ # @param [Hash] params Custom parameters for this SCM as delivered from the repository
+ # field.
+ #
+ # @param [Symbol] type SCM tag to determine the type this repository should be built as
+ #
+ # @raise [OpenProject::Scm::RepositoryBuildError]
+ # Raised when the instance could not be built
+ # given the parameters.
+ # @raise [::NameError] Raised when the given +vendor+ could not be resolved to a class.
+ def self.build(project, vendor, params, type)
+ klass = build_scm_class(vendor)
+
+ # We can't possibly know the form fields this particular vendor
+ # desires, so we allow it to filter them from raw params
+ # before building the instance with it.
+ args = klass.permitted_params(params)
+
+ repository = klass.new(args)
+ repository.attributes = args
+ repository.project = project
+
+ set_verified_type!(repository, type) unless type.nil?
+
+ repository.configure(type, args)
+
+ repository
end
- def self.available_scm
- subclasses.map { |klass| [klass.scm_name, klass.name] }
+ ##
+ # Build a temporary model instance of the given vendor for temporary use in forms.
+ # Will not receive any args.
+ def self.build_scm_class(vendor)
+ klass = OpenProject::Scm::Manager.registered[vendor]
+
+ if klass.nil?
+ raise OpenProject::Scm::Exceptions::RepositoryBuildError.new(
+ I18n.t('repositories.errors.disabled_or_unknown_vendor', vendor: vendor)
+ )
+ else
+ klass
+ end
end
- def self.factory(klass_name, *args)
- klass = "Repository::#{klass_name}".constantize
- klass.new(*args)
- rescue
- nil
+ ##
+ # Verifies that the chosen scm type can be selected
+ def self.set_verified_type!(repository, type)
+ if repository.class.available_types.include? type
+ repository.scm_type = type
+ else
+ raise OpenProject::Scm::Exceptions::RepositoryBuildError.new(
+ I18n.t('repositories.errors.disabled_or_unknown_type',
+ type: type,
+ vendor: repository.vendor)
+ )
+ end
+ end
+
+ ##
+ # Allow global permittible params. May be overridden by plugins
+ def self.permitted_params(params)
+ params.permit(:url)
end
def self.scm_adapter_class
nil
end
- def self.scm_command
- ret = ''
- begin
- ret = scm_adapter_class.client_command if scm_adapter_class
- rescue Redmine::Scm::Adapters::CommandFailed => e
- logger.error "scm: error during get command: #{e.message}"
- end
- ret
+ def self.vendor
+ name.demodulize
end
- def self.scm_version_string
- ret = ''
- begin
- ret = scm_adapter_class.client_version_string if scm_adapter_class
- rescue Redmine::Scm::Adapters::CommandFailed => e
- logger.error "scm: error during get version string: #{e.message}"
- end
- ret
- end
-
- def self.scm_available
- ret = false
- begin
- ret = scm_adapter_class.client_available if scm_adapter_class
- rescue Redmine::Scm::Adapters::CommandFailed => e
- logger.error "scm: error during get scm available: #{e.message}"
- end
- ret
- end
-
- def self.configured?
- true
- end
-
- private
-
# Strips url and root_url
def sanitize_urls
url.strip! if url.present?
@@ -322,4 +386,32 @@ class Repository < ActiveRecord::Base
self.class.connection.delete("DELETE FROM #{ci} WHERE #{ci}.changeset_id IN (SELECT #{cs}.id FROM #{cs} WHERE #{cs}.repository_id = #{id})")
self.class.connection.delete("DELETE FROM #{cs} WHERE #{cs}.repository_id = #{id}")
end
+
+ private
+
+ ##
+ # Create local managed repository request when the built instance
+ # is managed by OpenProject
+ def create_managed_repository
+ service = Scm::CreateManagedRepositoryService.new(self)
+ if service.call
+ true
+ else
+ raise OpenProject::Scm::Exceptions::RepositoryBuildError.new(
+ service.localized_rejected_reason
+ )
+ end
+ end
+
+ ##
+ # Destroy local managed repository request when the built instance
+ # is managed by OpenProject
+ def delete_managed_repository
+ service = Scm::DeleteManagedRepositoryService.new(self)
+ # Even if the service can't remove the physical repository,
+ # we should continue removing the associated instance.
+ service.call
+
+ true
+ end
end
diff --git a/app/models/repository/filesystem.rb b/app/models/repository/filesystem.rb
deleted file mode 100644
index b739abed64d..00000000000
--- a/app/models/repository/filesystem.rb
+++ /dev/null
@@ -1,100 +0,0 @@
-#-- encoding: UTF-8
-#-- copyright
-# OpenProject is a project management system.
-# Copyright (C) 2012-2015 the OpenProject Foundation (OPF)
-#
-# This program is free software; you can redistribute it and/or
-# modify it under the terms of the GNU General Public License version 3.
-#
-# OpenProject is a fork of ChiliProject, which is a fork of Redmine. The copyright follows:
-# Copyright (C) 2006-2013 Jean-Philippe Lang
-# Copyright (C) 2010-2013 the ChiliProject Team
-#
-# This program is free software; you can redistribute it and/or
-# modify it under the terms of the GNU General Public License
-# as published by the Free Software Foundation; either version 2
-# of the License, or (at your option) any later version.
-#
-# This program is distributed in the hope that it will be useful,
-# but WITHOUT ANY WARRANTY; without even the implied warranty of
-# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
-# GNU General Public License for more details.
-#
-# You should have received a copy of the GNU General Public License
-# along with this program; if not, write to the Free Software
-# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
-#
-# See doc/COPYRIGHT.rdoc for more details.
-#++
-
-require 'redmine/scm/adapters/filesystem_adapter'
-
-class Repository::Filesystem < Repository
- attr_protected :root_url
- validates_presence_of :url
-
- validate :validate_whitelisted_url,
- :validate_url_is_dir
-
- ATTRIBUTE_KEY_NAMES = {
- 'url' => 'Root directory',
- }
- def self.human_attribute_name(attribute_key_name, options = {})
- ATTRIBUTE_KEY_NAMES[attribute_key_name] || super
- end
-
- def self.scm_adapter_class
- Redmine::Scm::Adapters::FilesystemAdapter
- end
-
- def self.scm_name
- 'Filesystem'
- end
-
- def self.configured?
- !whitelisted_paths.empty?
- end
-
- def supports_all_revisions?
- false
- end
-
- def entries(path = nil, identifier = nil)
- scm.entries(path, identifier)
- end
-
- def fetch_changesets
- nil
- end
-
- private
-
- def self.whitelisted_paths
- OpenProject::Configuration['scm_filesystem_path_whitelist']
- end
-
- # validates that the url is a directory
- def validate_url_is_dir
- errors.add :url, :no_directory unless Dir.exists?(url)
- end
-
- # validate url against whitelisted urls as provided by the
- # scm_filesystem_path_whitelist configuration parameter.
- #
- # The url needs to exist and needs to match one of the directories
- # returned when globbing the configuration setting.
- def validate_whitelisted_url
- globbed_url = Dir.glob(url).first
-
- unless globbed_url
- errors.add :url, :not_whitelisted
- return
- end
-
- globbed_whitelisted = Dir.glob(self.class.whitelisted_paths)
-
- unless globbed_whitelisted.include?(globbed_url)
- errors.add :url, :not_whitelisted
- end
- end
-end
diff --git a/app/models/repository/git.rb b/app/models/repository/git.rb
index 0bcec746adf..948358a508d 100644
--- a/app/models/repository/git.rb
+++ b/app/models/repository/git.rb
@@ -27,25 +27,53 @@
# See doc/COPYRIGHT.rdoc for more details.
#++
-require 'redmine/scm/adapters/git_adapter'
+require 'open_project/scm/adapters/git'
class Repository::Git < Repository
attr_protected :root_url
validates_presence_of :url
- ATTRIBUTE_KEY_NAMES = {
- 'url' => 'Path to repository',
- }
- def self.human_attribute_name(attribute_key_name, options = {})
- ATTRIBUTE_KEY_NAMES[attribute_key_name] || super
- end
-
def self.scm_adapter_class
- Redmine::Scm::Adapters::GitAdapter
+ OpenProject::Scm::Adapters::Git
end
- def self.scm_name
- 'Git'
+ def configure(scm_type, _args)
+ if scm_type == self.class.managed_type
+ unless manageable?
+ raise OpenProject::Scm::Exceptions::RepositoryBuildError.new(
+ I18n.t('repositories.managed.error_not_manageable')
+ )
+ end
+
+ self.root_url = managed_repository_path
+ self.url = managed_repository_url
+ end
+ end
+
+ def self.permitted_params(params)
+ super(params).merge(params.permit(:path_encoding))
+ end
+
+ def self.supported_types
+ types = [:local]
+ types << managed_type if manageable?
+
+ types
+ end
+
+ def managed_repo_created
+ scm.initialize_bare_git
+ end
+
+ def repository_identifier
+ "#{super}.git"
+ end
+
+ ##
+ # Git doesn't like local urls when visiting
+ # the repository, thus always use the path.
+ def managed_repository_url
+ managed_repository_path
end
def supports_directory_revisions?
diff --git a/app/models/repository/subversion.rb b/app/models/repository/subversion.rb
index 18a7010fafb..06f448dea0a 100644
--- a/app/models/repository/subversion.rb
+++ b/app/models/repository/subversion.rb
@@ -27,7 +27,7 @@
# See doc/COPYRIGHT.rdoc for more details.
#++
-require 'redmine/scm/adapters/subversion_adapter'
+require 'open_project/scm/adapters/subversion'
class Repository::Subversion < Repository
attr_protected :root_url
@@ -35,11 +35,35 @@ class Repository::Subversion < Repository
validates_format_of :url, with: /\A(http|https|svn(\+[^\s:\/\\]+)?|file):\/\/.+\z/i
def self.scm_adapter_class
- Redmine::Scm::Adapters::SubversionAdapter
+ OpenProject::Scm::Adapters::Subversion
end
- def self.scm_name
- 'Subversion'
+ def configure(scm_type, _args)
+ if scm_type == self.class.managed_type
+ unless manageable?
+ raise OpenProject::Scm::Exceptions::RepositoryBuildError.new(
+ I18n.t('repositories.managed.error_not_manageable')
+ )
+ end
+
+ self.root_url = managed_repository_path
+ self.url = managed_repository_url
+ end
+ end
+
+ def self.permitted_params(params)
+ super(params).merge(params.permit(:login, :password))
+ end
+
+ def self.supported_types
+ types = [:existing]
+ types << managed_type if manageable?
+
+ types
+ end
+
+ def managed_repo_created
+ scm.create_empty_svn
end
def supports_directory_revisions?
diff --git a/app/models/user.rb b/app/models/user.rb
index 33af8414d9c..1ede9e90046 100644
--- a/app/models/user.rb
+++ b/app/models/user.rb
@@ -710,6 +710,14 @@ class User < Principal
@current_user ||= User.anonymous
end
+ def self.execute_as(user)
+ previous_user = User.current
+ User.current = user
+ yield
+ ensure
+ User.current = previous_user
+ end
+
def roles(project)
User.current.admin? ? Role.all : User.current.roles_for_project(project)
end
diff --git a/app/models/wiki_content.rb b/app/models/wiki_content.rb
index 2dbcda6c059..a566694f4eb 100644
--- a/app/models/wiki_content.rb
+++ b/app/models/wiki_content.rb
@@ -68,8 +68,7 @@ class WikiContent < ActiveRecord::Base
# Returns the mail adresses of users that should be notified
def recipients
notified = project.notified_users
- notified.reject! do |user| !visible?(user) end
- notified.map(&:mail)
+ notified.select { |user| visible?(user) }
end
# FIXME: Deprecate
@@ -87,57 +86,4 @@ class WikiContent < ActiveRecord::Base
def comments_to_journal_notes
add_journal author, comments
end
-
- # FIXME: This is for backwards compatibility only. Remove once we decide it is not needed anymore
- # WikiContentJournal.class_eval do
- # attr_protected :data
- # after_save :compress_version_text
- #
- # # Wiki Content might be large and the data should possibly be compressed
- # def compress_version_text
- # self.text = changed_data["text"].last if changed_data["text"]
- # self.text ||= self.journaled.text
- # end
- #
- # def text=(plain)
- # case Setting.wiki_compression
- # when "gzip"
- # begin
- # text_hash :text => Zlib::Deflate.deflate(plain, Zlib::BEST_COMPRESSION), :compression => Setting.wiki_compression
- # rescue
- # text_hash :text => plain, :compression => ''
- # end
- # else
- # text_hash :text => plain, :compression => ''
- # end
- # plain
- # end
- #
- # def text_hash(hash)
- # changed_data.delete("text")
- # changed_data["data"] = hash[:text]
- # changed_data["compression"] = hash[:compression]
- # update_attribute(:changed_data, changed_data)
- # end
- #
- # def text
- # @text ||= case changed_data["compression"]
- # when "gzip"
- # Zlib::Inflate.inflate(changed_data["data"])
- # else
- # # uncompressed data
- # changed_data["data"]
- # end
- # end
- #
- # # Returns the previous version or nil
- # def previous
- # @previous ||= journaled.journals.at(version - 1)
- # end
- #
- # # FIXME: Deprecate
- # def versioned
- # journaled
- # end
- # end
end
diff --git a/app/models/wiki_content_observer.rb b/app/models/wiki_content_observer.rb
index 0091a7802a9..4499c686747 100644
--- a/app/models/wiki_content_observer.rb
+++ b/app/models/wiki_content_observer.rb
@@ -31,8 +31,7 @@ class WikiContentObserver < ActiveRecord::Observer
def after_create(wiki_content)
if Setting.notified_events.include?('wiki_content_added')
recipients = wiki_content.recipients + wiki_content.page.wiki.watcher_recipients
- users = User.find_all_by_mails(recipients.uniq)
- users.each do |user|
+ recipients.uniq.each do |user|
UserMailer.wiki_content_added(user, wiki_content, User.current).deliver
end
end
@@ -40,9 +39,10 @@ class WikiContentObserver < ActiveRecord::Observer
def after_update(wiki_content)
if wiki_content.text_changed? && Setting.notified_events.include?('wiki_content_updated')
- recipients = wiki_content.recipients + wiki_content.page.wiki.watcher_recipients + wiki_content.page.watcher_recipients
- users = User.find_all_by_mails(recipients.uniq)
- users.each do |user|
+ recipients = wiki_content.recipients
+ recipients += wiki_content.page.wiki.watcher_recipients
+ recipients += wiki_content.page.watcher_recipients
+ recipients.uniq.each do |user|
UserMailer.wiki_content_updated(user, wiki_content, User.current).deliver
end
end
diff --git a/app/models/work_package.rb b/app/models/work_package.rb
index 90d4e264881..275df802b39 100644
--- a/app/models/work_package.rb
+++ b/app/models/work_package.rb
@@ -180,6 +180,11 @@ class WorkPackage < ActiveRecord::Base
acts_as_journalized except: ['root_id']
+ acts_as_countable :required_project_storage,
+ label: 'attributes.attachments',
+ collection: true,
+ countable: Proc.new { joins(:attachments).sum(:filesize).to_i }
+
# This one is here only to ease reading
module JournalizedProcs
def self.event_title
@@ -202,7 +207,7 @@ class WorkPackage < ActiveRecord::Base
journal = o.last_journal
t = 'work_package'
- t << if journal && journal.changed_data.empty? && !journal.initial?
+ t << if journal && journal.details.empty? && !journal.initial?
'-note'
else
status = Status.find_by(id: o.status_id)
@@ -432,6 +437,7 @@ class WorkPackage < ActiveRecord::Base
# TODO: move into Business Object and rename to update
# update for now is a private method defined by AR
def update_by!(user, attributes)
+ attributes = attributes.dup
raw_attachments = attributes.delete(:attachments)
update_by(user, attributes)
@@ -479,16 +485,8 @@ class WorkPackage < ActiveRecord::Base
@spent_hours ||= compute_spent_hours(usr)
end
- # Moves/copies an work_package to a new project and type
- # Returns the moved/copied work_package on success, false on failure
- def move_to_project(*args)
- WorkPackage.transaction do
- move_to_project_without_transaction(*args) || raise(ActiveRecord::Rollback)
- end || false
- end
-
# >>> issues.rb >>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>
- # Returns the mail addresses of users that should be notified
+ # Returns users that should be notified
def recipients
notified = project.notified_users
# Author and assignee are always notified unless they have been
@@ -503,8 +501,7 @@ class WorkPackage < ActiveRecord::Base
end
notified.uniq!
# Remove users that can not view the issue
- notified.reject! do |user| !visible?(user) end
- notified.map(&:mail)
+ notified.select { |user| visible?(user) }
end
def done_ratio
@@ -667,77 +664,6 @@ class WorkPackage < ActiveRecord::Base
done_date <= Date.today
end
- def move_to_project_without_transaction(new_project, new_type = nil, options = {})
- options ||= {}
- work_package = options[:copy] ? self.class.new.copy_from(self) : self
-
- if new_project && work_package.project_id != new_project.id
- delete_relations(work_package)
- # work_package is moved to another project
- # reassign to the category with same name if any
- new_category = if work_package.category.nil?
- nil
- else
- new_project.categories.find_by(name: work_package.category.name)
- end
- work_package.category = new_category
- # Keep the fixed_version if it's still valid in the new_project
- unless new_project.shared_versions.include?(work_package.fixed_version)
- work_package.fixed_version = nil
- end
-
- work_package.project = new_project
-
- enforce_cross_project_settings(work_package)
- end
- if new_type
- work_package.type = new_type
- work_package.reset_custom_values!
- end
- # Allow bulk setting of attributes on the work_package
- if options[:attributes]
- # before setting the attributes, we need to remove the move-related fields
- work_package.attributes =
- options[:attributes].except(:copy, :new_project_id, :new_type_id, :follow, :ids)
- .reject { |_key, value| value.blank? }
- end # FIXME this eliminates the case, where values shall be bulk-assigned to null,
- # but this needs to work together with the permit
- if options[:copy]
- work_package.author = User.current
- work_package.custom_field_values =
- custom_field_values.inject({}) do |h, v|
- h[v.custom_field_id] = v.value
- h
- end
- work_package.status = if options[:attributes] && options[:attributes][:status_id].present?
- Status.find_by(id: options[:attributes][:status_id])
- else
- status
- end
- else
- work_package.add_journal User.current, options[:journal_note] if options[:journal_note]
- end
-
- if work_package.save
- if options[:copy]
- create_and_save_journal_note work_package, options[:journal_note]
- else
- # Manually update project_id on related time entries
- TimeEntry.where(work_package_id: id).update_all("project_id = #{new_project.id}")
-
- work_package.children.each do |child|
- unless child.move_to_project_without_transaction(new_project)
- # Move failed and transaction was rollback'd
- return false
- end
- end
- end
- else
- return false
- end
- work_package
- end
-
# check if user is allowed to edit WorkPackage Journals.
# see Redmine::Acts::Journalized::Permissions#journal_editable_by
def editable_by?(user)
@@ -842,22 +768,10 @@ class WorkPackage < ActiveRecord::Base
reload(select: [:lock_version, :created_at, :updated_at])
end
- # Returns an array of projects that current user can move issues to
- def self.allowed_target_projects_on_move
- projects = []
- if User.current.admin?
- # admin is allowed to move issues to any active (visible) project
- projects = Project.visible
- elsif User.current.logged?
- if Role.non_member.allowed_to?(:move_work_packages)
- projects = Project.visible
- else
- User.current.memberships.each do |m|
- projects << m.project if m.roles.detect { |r| r.allowed_to?(:move_work_packages) }
- end
- end
- end
- projects
+ # Returns a scope for the projects
+ # the user is allowed to move a work package to
+ def self.allowed_target_projects_on_move(user)
+ Project.where(Project.allowed_to_condition(user, :move_work_packages))
end
# Do not redefine alias chain on reload (see #4838)
@@ -1105,21 +1019,6 @@ class WorkPackage < ActiveRecord::Base
end
end
- def create_and_save_journal_note(work_package, journal_note)
- if work_package && journal_note
- work_package.add_journal User.current, journal_note
- work_package.save!
- end
- end
-
- def enforce_cross_project_settings(work_package)
- parent_in_project =
- work_package.parent.nil? || work_package.parent.project == work_package.project
-
- work_package.parent_id =
- nil unless Setting.cross_project_work_package_relations? || parent_in_project
- end
-
def compute_spent_hours(usr = User.current)
spent_time = TimeEntry.visible(usr)
.on_work_packages(self_and_descendants.visible(usr))
diff --git a/app/models/work_package_observer.rb b/app/services/add_attachment_service.rb
similarity index 58%
rename from app/models/work_package_observer.rb
rename to app/services/add_attachment_service.rb
index 6570b6749a3..5649ed111e2 100644
--- a/app/models/work_package_observer.rb
+++ b/app/services/add_attachment_service.rb
@@ -1,4 +1,3 @@
-#-- encoding: UTF-8
#-- copyright
# OpenProject is a project management system.
# Copyright (C) 2012-2015 the OpenProject Foundation (OPF)
@@ -27,39 +26,38 @@
# See doc/COPYRIGHT.rdoc for more details.
#++
-class WorkPackageObserver < ActiveRecord::Observer
- attr_accessor :send_notification
+class AddAttachmentService
+ attr_reader :container, :author
- def after_create(work_package)
- if send_notification
- recipients = work_package.recipients + work_package.watcher_recipients
- users = User.find_all_by_mails(recipients.uniq)
-
- users.each do |user|
- notify(user, work_package)
- end
- end
- clear_notification
+ def initialize(container, author:)
+ @container = container
+ @author = author
end
##
- # Notifies the user of the created work package.
- def notify(user, work_package)
- job = DeliverWorkPackageCreatedJob.new(user.id, work_package.id, User.current.id)
+ # Adds and saves the uploaded file as attachment of the given container.
+ # In case the container supports it, a journal will be written.
+ #
+ # An ActiveRecord::RecordInvalid error is raised if any record can't be saved.
+ def add_attachment(uploaded_file:, description:)
+ attachment = Attachment.new(file: uploaded_file,
+ container: container,
+ description: description,
+ author: author)
+ save attachment
- Delayed::Job.enqueue job
- end
-
- # Wrap send_notification so it defaults to true, when it's nil
- def send_notification
- return true if @send_notification.nil?
- @send_notification
+ attachment
end
private
- # Need to clear the notification setting after each usage otherwise it might be cached
- def clear_notification
- @send_notification = true
+ def save(attachment)
+ ActiveRecord::Base.transaction do
+ attachment.save!
+ if container.respond_to? :add_journal
+ container.add_journal author
+ container.save!
+ end
+ end
end
end
diff --git a/app/services/create_work_package_service.rb b/app/services/create_work_package_service.rb
index 15f92033f25..d38c731e79d 100644
--- a/app/services/create_work_package_service.rb
+++ b/app/services/create_work_package_service.rb
@@ -34,7 +34,7 @@ class CreateWorkPackageService
self.user = user
self.project = project
- WorkPackageObserver.instance.send_notification = send_notifications
+ JournalManager.send_notification = send_notifications
end
def create
diff --git a/app/services/move_work_package_service.rb b/app/services/move_work_package_service.rb
new file mode 100644
index 00000000000..e4fc2389892
--- /dev/null
+++ b/app/services/move_work_package_service.rb
@@ -0,0 +1,184 @@
+
+# Moves/copies an work_package to a new project and type
+# Returns the moved/copied work_package on success, false on failure
+
+class MoveWorkPackageService
+ attr_accessor :work_package,
+ :user
+
+ def initialize(work_package, user)
+ self.work_package = work_package
+ self.user = user
+ end
+
+ def call(new_project, new_type = nil, options = {})
+ if options[:no_transaction]
+ move_without_transaction(new_project, new_type, options)
+ else
+ WorkPackage.transaction do
+ move_without_transaction(new_project, new_type, options) ||
+ raise(ActiveRecord::Rollback)
+ end || false
+ end
+ end
+
+ private
+
+ def move_without_transaction(new_project, new_type = nil, options = {})
+ attributes = options[:attributes] || {}
+
+ modified_work_package = copy_or_move(options[:copy], new_project, new_type, attributes)
+
+ if options[:copy]
+ return false unless copy(modified_work_package, attributes, options)
+ else
+ return false unless move(modified_work_package, new_project, options)
+ end
+
+ modified_work_package
+ end
+
+ def copy_or_move(make_copy, new_project, new_type, attributes)
+ modified_work_package = if make_copy
+ WorkPackage.new.copy_from(work_package)
+ else
+ work_package
+ end
+
+ move_to_project(modified_work_package, new_project)
+
+ move_to_type(modified_work_package, new_type)
+
+ bulk_assign_attributes(modified_work_package, attributes)
+
+ modified_work_package
+ end
+
+ def copy(modified_work_package, attributes, options)
+ set_default_values_on_copy(modified_work_package, attributes)
+
+ return false unless modified_work_package.save
+
+ create_and_save_journal_note modified_work_package, options[:journal_note]
+
+ true
+ end
+
+ def move(modified_work_package, new_project, options)
+ if options[:journal_note]
+ modified_work_package.add_journal user, options[:journal_note]
+ end
+
+ return false unless modified_work_package.save
+
+ move_time_entries(modified_work_package, new_project)
+
+ return false unless move_children(modified_work_package, new_project, options)
+
+ true
+ end
+
+ def move_to_project(work_package, new_project)
+ if new_project &&
+ work_package.project_id != new_project.id &&
+ allowed_to_move_to_project?(new_project)
+
+ work_package.delete_relations(work_package)
+
+ reassign_category(work_package, new_project)
+
+ # Keep the fixed_version if it's still valid in the new_project
+ unless new_project.shared_versions.include?(work_package.fixed_version)
+ work_package.fixed_version = nil
+ end
+
+ work_package.project = new_project
+
+ enforce_cross_project_settings(work_package)
+ end
+ end
+
+ def move_to_type(work_package, new_type)
+ if new_type
+ work_package.type = new_type
+ work_package.reset_custom_values!
+ end
+ end
+
+ def bulk_assign_attributes(work_package, attributes)
+ # Allow bulk setting of attributes on the work_package
+ if attributes
+ # before setting the attributes, we need to remove the move-related fields
+ work_package.attributes =
+ attributes.except(:copy, :new_project_id, :new_type_id, :follow, :ids)
+ .reject { |_key, value| value.blank? }
+ end # FIXME this eliminates the case, where values shall be bulk-assigned to null,
+ # but this needs to work together with the permit
+ end
+
+ def set_default_values_on_copy(work_package, attributes)
+ work_package.author = user
+
+ assign_status_or_default(work_package, attributes[:status_id])
+ end
+
+ def move_children(work_package, new_project, options)
+ work_package.children.each do |child|
+ child_service = self.class.new(child, user)
+ unless child_service.call(new_project, nil, options.merge(no_transaction: true))
+ # Move failed and transaction was rollback'd
+ return false
+ end
+ end
+
+ true
+ end
+
+ def move_time_entries(work_package, new_project)
+ # Manually update project_id on related time entries
+ TimeEntry.update_all("project_id = #{new_project.id}", work_package_id: work_package.id)
+ end
+
+ def enforce_cross_project_settings(work_package)
+ parent_in_project =
+ work_package.parent.nil? || work_package.parent.project == work_package.project
+
+ work_package.parent_id =
+ nil unless Setting.cross_project_work_package_relations? || parent_in_project
+ end
+
+ def create_and_save_journal_note(work_package, journal_note)
+ if journal_note
+ work_package.add_journal user, journal_note
+ work_package.save!
+ end
+ end
+
+ def allowed_to_move_to_project?(new_project)
+ WorkPackage
+ .allowed_target_projects_on_move(user)
+ .where(id: new_project.id)
+ .exists?
+ end
+
+ def reassign_category(work_package, new_project)
+ # work_package is moved to another project
+ # reassign to the category with same name if any
+ new_category = if work_package.category.nil?
+ nil
+ else
+ new_project.categories.find_by_name(work_package.category.name)
+ end
+ work_package.category = new_category
+ end
+
+ def assign_status_or_default(work_package, status_id)
+ status = if status_id.present?
+ Status.find_by_id(status_id)
+ else
+ self.work_package.status
+ end
+
+ work_package.status = status
+ end
+end
diff --git a/app/services/scm/create_managed_repository_service.rb b/app/services/scm/create_managed_repository_service.rb
new file mode 100644
index 00000000000..2a3025f64c9
--- /dev/null
+++ b/app/services/scm/create_managed_repository_service.rb
@@ -0,0 +1,82 @@
+#-- encoding: UTF-8
+#-- copyright
+# OpenProject is a project management system.
+# Copyright (C) 2012-2015 the OpenProject Foundation (OPF)
+#
+# This program is free software; you can redistribute it and/or
+# modify it under the terms of the GNU General Public License version 3.
+#
+# OpenProject is a fork of ChiliProject, which is a fork of Redmine. The copyright follows:
+# Copyright (C) 2006-2013 Jean-Philippe Lang
+# Copyright (C) 2010-2013 the ChiliProject Team
+#
+# This program is free software; you can redistribute it and/or
+# modify it under the terms of the GNU General Public License
+# as published by the Free Software Foundation; either version 2
+# of the License, or (at your option) any later version.
+#
+# This program is distributed in the hope that it will be useful,
+# but WITHOUT ANY WARRANTY; without even the implied warranty of
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+# GNU General Public License for more details.
+#
+# You should have received a copy of the GNU General Public License
+# along with this program; if not, write to the Free Software
+# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
+#
+# See doc/COPYRIGHT.rdoc for more details.
+#++
+
+##
+# Implements the creation of a local repository.
+Scm::CreateManagedRepositoryService = Struct.new :repository do
+ ##
+ # Checks if a given repository may be created and managed locally.
+ # Registers an job to create the repository on disk.
+ #
+ # @return True if the repository creation request has been initiated, false otherwise.
+ def call
+ if repository.managed? && repository.manageable?
+
+ # Cowardly refusing to override existing local repository
+ return false if repository_exists?
+
+ ##
+ # We want to move this functionality in a Delayed Job,
+ # but this heavily interferes with the error handling of the whole
+ # repository management process.
+ # Instead, this will be refactored into a single service wrapper for
+ # creating and deleting repositories, which provides transactional DB access
+ # as well as filesystem access.
+ Scm::CreateRepositoryJob.new(repository).perform
+ return true
+ end
+
+ false
+ rescue Errno::EACCES
+ @rejected = I18n.t('repositories.errors.path_permission_failed',
+ path: repository.root_url)
+ false
+ rescue SystemCallError => e
+ @rejected = I18n.t('repositories.errors.filesystem_access_failed',
+ message: e.message)
+ false
+ end
+
+ ##
+ # Returns the error symbol
+ def localized_rejected_reason
+ @rejected ||= I18n.t('repositories.errors.not_manageable')
+ end
+
+ private
+
+ ##
+ # Test if the repository exists already on filesystem.
+ def repository_exists?
+ if File.directory?(repository.root_url)
+ @rejected = I18n.t('repositories.errors.exists_on_filesystem')
+ return true
+ end
+ end
+end
diff --git a/spec/legacy/unit/default_data_spec.rb b/app/services/scm/delete_managed_repository_service.rb
similarity index 56%
rename from spec/legacy/unit/default_data_spec.rb
rename to app/services/scm/delete_managed_repository_service.rb
index 22631167da0..c5990bc1696 100644
--- a/spec/legacy/unit/default_data_spec.rb
+++ b/app/services/scm/delete_managed_repository_service.rb
@@ -26,43 +26,35 @@
#
# See doc/COPYRIGHT.rdoc for more details.
#++
-require 'legacy_spec_helper'
-describe Redmine::DefaultData do
- include Redmine::I18n
+##
+# Implements the asynchronous deletion of a local repository.
+Scm::DeleteManagedRepositoryService = Struct.new :repository do
+ ##
+ # Checks if a given repository may be deleted
+ # Registers an asynchronous job to delete the repository on disk.
+ #
+ def call
+ if repository.managed?
- before do
- delete_loaded_data!
- assert Redmine::DefaultData::Loader::no_data?
- end
+ # Create necessary changes to repository to mark
+ # it as managed by OP, but delete asynchronously.
+ managed_path = repository.root_url
- it 'should no_data' do
- Redmine::DefaultData::Loader::load
- assert !Redmine::DefaultData::Loader::no_data?
-
- delete_loaded_data!
- assert Redmine::DefaultData::Loader::no_data?
- end
-
- it 'should load' do
- valid_languages.each do |lang|
- begin
- delete_loaded_data!
- assert Redmine::DefaultData::Loader::load(lang)
- assert_not_nil IssuePriority.first
- assert_not_nil TimeEntryActivity.first
- rescue ActiveRecord::RecordInvalid => e
- assert false, ":#{lang} default data is invalid (#{e.message})."
+ if File.directory?(managed_path)
+ ##
+ # We want to move this functionality in a Delayed Job,
+ # but this heavily interferes with the error handling of the whole
+ # repository management process.
+ # Instead, this will be refactored into a single service wrapper for
+ # creating and deleting repositories, which provides transactional DB access
+ # as well as filesystem access.
+ Scm::DeleteRepositoryJob.new(managed_path).perform
end
+
+ true
+ else
+ false
end
end
-
- private
-
- def delete_loaded_data!
- Role.delete_all('builtin = 0')
- ::Type.delete_all(is_standard: false)
- Status.delete_all
- Enumeration.delete_all
- end
end
diff --git a/app/services/scm/repository_factory_service.rb b/app/services/scm/repository_factory_service.rb
new file mode 100644
index 00000000000..03435e67ae0
--- /dev/null
+++ b/app/services/scm/repository_factory_service.rb
@@ -0,0 +1,92 @@
+#-- encoding: UTF-8
+#-- copyright
+# OpenProject is a project management system.
+# Copyright (C) 2012-2015 the OpenProject Foundation (OPF)
+#
+# This program is free software; you can redistribute it and/or
+# modify it under the terms of the GNU General Public License version 3.
+#
+# OpenProject is a fork of ChiliProject, which is a fork of Redmine. The copyright follows:
+# Copyright (C) 2006-2013 Jean-Philippe Lang
+# Copyright (C) 2010-2013 the ChiliProject Team
+#
+# This program is free software; you can redistribute it and/or
+# modify it under the terms of the GNU General Public License
+# as published by the Free Software Foundation; either version 2
+# of the License, or (at your option) any later version.
+#
+# This program is distributed in the hope that it will be useful,
+# but WITHOUT ANY WARRANTY; without even the implied warranty of
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+# GNU General Public License for more details.
+#
+# You should have received a copy of the GNU General Public License
+# along with this program; if not, write to the Free Software
+# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
+#
+# See doc/COPYRIGHT.rdoc for more details.
+#++
+
+##
+# Implements a repository factory for building temporary and permanent repositories.
+Scm::RepositoryFactoryService = Struct.new :project, :params do
+ attr_reader :repository
+
+ ##
+ # Build a full repository from a given scm_type
+ # and persists it.
+ #
+ # @return [Boolean] true iff the repository was built
+ def build_and_save
+ build_guarded do
+ repository = build_with_type(params.fetch(:scm_type).to_sym)
+ if repository.save
+ repository
+ else
+ raise OpenProject::Scm::Exceptions::RepositoryBuildError.new(
+ repository.errors.full_messages.join("\n")
+ )
+ end
+ end
+ end
+
+ ##
+ # Build a temporary repository used only for determining availabe settings and types
+ # of that particular vendor.
+ #
+ # @return [Boolean] true iff the repository was built
+ def build_temporary
+ build_guarded do
+ build_with_type(nil)
+ end
+ end
+
+ def build_error
+ I18n.t('repositories.errors.build_failed', reason: @build_failed_msg)
+ end
+
+ private
+
+ ##
+ # Helper to actually build the repository and return it.
+ # May raise +OpenProject::Scm::Exceptions::RepositoryBuildError+ internally.
+ #
+ # @param [Symbol] scm_type Type to build the repository with. May be nil
+ # during temporary build
+ def build_with_type(scm_type)
+ Repository.build(
+ project,
+ params[:scm_vendor],
+ params.fetch(:repository, {}),
+ scm_type
+ )
+ end
+
+ def build_guarded
+ @repository = yield
+ @repository.present?
+ rescue OpenProject::Scm::Exceptions::RepositoryBuildError => e
+ @build_failed_msg = e.message
+ nil
+ end
+end
diff --git a/app/services/update_work_package_service.rb b/app/services/update_work_package_service.rb
index ce2aa1d0113..7a613b115f6 100644
--- a/app/services/update_work_package_service.rb
+++ b/app/services/update_work_package_service.rb
@@ -35,7 +35,7 @@ class UpdateWorkPackageService
self.work_package = work_package
self.permitted_params = permitted_params
- JournalObserver.instance.send_notification = send_notifications
+ JournalManager.send_notification = send_notifications
end
def update
diff --git a/app/views/admin/projects.html.erb b/app/views/admin/projects.html.erb
index ce79454bb4a..1fecda6d316 100644
--- a/app/views/admin/projects.html.erb
+++ b/app/views/admin/projects.html.erb
@@ -27,10 +27,6 @@ See doc/COPYRIGHT.rdoc for more details.
++#%>
-
- <%= render :partial => 'no_data' if @no_configuration_data %>
-
<%= link_to(l(:button_archive),
diff --git a/app/views/api/v2/planning_element_journals/_journal.api.rabl b/app/views/api/v2/planning_element_journals/_journal.api.rabl
index 3039ef0c7d0..a1f424d8112 100644
--- a/app/views/api/v2/planning_element_journals/_journal.api.rabl
+++ b/app/views/api/v2/planning_element_journals/_journal.api.rabl
@@ -37,13 +37,13 @@ node :user do |journal|
end
node :changes do |journal|
- journal.changed_data.map do |attribute, changes|
+ journal.details.map do |attribute, details|
user_friendly_attribute, old, new = user_friendly_change(journal, attribute)
{
technical: {
name: attribute.to_s,
- old: changes.first,
- new: changes.last
+ old: details.first,
+ new: details.last
},
user_friendly: {
name: user_friendly_attribute,
@@ -57,4 +57,4 @@ end
node :created_on do |journal|
journal.created_at.utc.iso8601
-end
\ No newline at end of file
+end
diff --git a/app/views/auth_sources/index.html.erb b/app/views/auth_sources/index.html.erb
index 9a2e21c0e31..69325664a0c 100644
--- a/app/views/auth_sources/index.html.erb
+++ b/app/views/auth_sources/index.html.erb
@@ -30,7 +30,7 @@ See doc/COPYRIGHT.rdoc for more details.
<% html_title l(:label_administration), l(:label_auth_source_plural) %>
<%= toolbar title: l(:label_auth_source_plural) do %>
- <%= link_to new_auth_source_path, class: 'button -alt-highlight' do %>
+ <%= link_to({ action: 'new' }, class: 'button -alt-highlight') do %>
<%= l(:label_auth_source_new) %>
<% end %>
diff --git a/app/views/journals/index.atom.builder b/app/views/journals/index.atom.builder
index adfef88f18d..e1aa7f08a33 100644
--- a/app/views/journals/index.atom.builder
+++ b/app/views/journals/index.atom.builder
@@ -47,7 +47,7 @@ xml.feed "xmlns" => "http://www.w3.org/2005/Atom" do
end
xml.content "type" => "html" do
xml.text! '
'
- change.changed_data.each do |detail|
+ change.details.each do |detail|
change_content = change.render_detail(detail, :no_html => false)
xml.text!(content_tag(:li, change_content)) if change_content.present?
end
diff --git a/app/views/layouts/angular.html.erb b/app/views/layouts/angular.html.erb
index 8c065a3bebb..06fe86891b4 100644
--- a/app/views/layouts/angular.html.erb
+++ b/app/views/layouts/angular.html.erb
@@ -66,6 +66,7 @@ See doc/COPYRIGHT.rdoc for more details.
+
<% main_menu = render_main_menu(@project) %>
<% side_displayed = content_for?(:sidebar) || content_for?(:main_menu) || !main_menu.blank? %>
<%= (show_decoration) ? '' : 'nomenus' %>"
diff --git a/app/views/projects/form/_project_attributes.html.erb b/app/views/projects/form/_project_attributes.html.erb
index d8d06109168..aaefb06b8c9 100644
--- a/app/views/projects/form/_project_attributes.html.erb
+++ b/app/views/projects/form/_project_attributes.html.erb
@@ -47,3 +47,5 @@ See doc/COPYRIGHT.rdoc for more details.
locals: { form: form } %>
<%= render partial: "projects/form/attributes/responsible_id",
locals: { form: form } %>
+<%= render partial: "projects/form/attributes/storage",
+ locals: { form: form } %>
diff --git a/app/views/projects/form/attributes/_storage.html.erb b/app/views/projects/form/attributes/_storage.html.erb
new file mode 100644
index 00000000000..33682b22444
--- /dev/null
+++ b/app/views/projects/form/attributes/_storage.html.erb
@@ -0,0 +1,42 @@
+<%#-- copyright
+OpenProject is a project management system.
+Copyright (C) 2012-2015 the OpenProject Foundation (OPF)
+
+This program is free software; you can redistribute it and/or
+modify it under the terms of the GNU General Public License version 3.
+
+OpenProject is a fork of ChiliProject, which is a fork of Redmine. The copyright follows:
+Copyright (C) 2006-2013 Jean-Philippe Lang
+Copyright (C) 2010-2013 the ChiliProject Team
+
+This program is free software; you can redistribute it and/or
+modify it under the terms of the GNU General Public License
+as published by the Free Software Foundation; either version 2
+of the License, or (at your option) any later version.
+
+This program is distributed in the hope that it will be useful,
+but WITHOUT ANY WARRANTY; without even the implied warranty of
+MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+GNU General Public License for more details.
+
+You should have received a copy of the GNU General Public License
+along with this program; if not, write to the Free Software
+Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
+
+See doc/COPYRIGHT.rdoc for more details.
+
+++#%>
+<%
+ project = form.object
+ storage = project.required_storage
+%>
+
+<% if storage.key?(:total) && storage[:total] > 0 %>
+
+<% end %>
diff --git a/app/views/projects/settings/_repository.html.erb b/app/views/projects/settings/_repository.html.erb
deleted file mode 100644
index 88289fecc5b..00000000000
--- a/app/views/projects/settings/_repository.html.erb
+++ /dev/null
@@ -1,64 +0,0 @@
-<%#-- copyright
-OpenProject is a project management system.
-Copyright (C) 2012-2015 the OpenProject Foundation (OPF)
-
-This program is free software; you can redistribute it and/or
-modify it under the terms of the GNU General Public License version 3.
-
-OpenProject is a fork of ChiliProject, which is a fork of Redmine. The copyright follows:
-Copyright (C) 2006-2013 Jean-Philippe Lang
-Copyright (C) 2010-2013 the ChiliProject Team
-
-This program is free software; you can redistribute it and/or
-modify it under the terms of the GNU General Public License
-as published by the Free Software Foundation; either version 2
-of the License, or (at your option) any later version.
-
-This program is distributed in the hope that it will be useful,
-but WITHOUT ANY WARRANTY; without even the implied warranty of
-MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
-GNU General Public License for more details.
-
-You should have received a copy of the GNU General Public License
-along with this program; if not, write to the Free Software
-Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
-
-See doc/COPYRIGHT.rdoc for more details.
-
-++#%>
-
-<%= remote_form_for :repository, @repository,
- :url => { :controller => '/repositories', :action => 'edit', :id => @project },
- :builder => TabularFormBuilder,
- :lang => current_language,
- html: { class: 'form' } do |f| %>
-
- <%= error_messages_for 'repository' %>
-
-
<% end %>
<% end %>
diff --git a/app/views/repositories/destroy_info.html.erb b/app/views/repositories/destroy_info.html.erb
new file mode 100644
index 00000000000..bdec0a349be
--- /dev/null
+++ b/app/views/repositories/destroy_info.html.erb
@@ -0,0 +1,53 @@
+<%#-- copyright
+OpenProject is a project management system.
+Copyright (C) 2012-2015 the OpenProject Foundation (OPF)
+
+This program is free software; you can redistribute it and/or
+modify it under the terms of the GNU General Public License version 3.
+
+OpenProject is a fork of ChiliProject, which is a fork of Redmine. The copyright follows:
+Copyright (C) 2006-2013 Jean-Philippe Lang
+Copyright (C) 2010-2013 the ChiliProject Team
+
+This program is free software; you can redistribute it and/or
+modify it under the terms of the GNU General Public License
+as published by the Free Software Foundation; either version 2
+of the License, or (at your option) any later version.
+
+This program is distributed in the hope that it will be useful,
+but WITHOUT ANY WARRANTY; without even the implied warranty of
+MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+GNU General Public License for more details.
+
+You should have received a copy of the GNU General Public License
+along with this program; if not, write to the Free Software
+Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
+
+See doc/COPYRIGHT.rdoc for more details.
+
+++#%>
+<%= toolbar title: l(:label_confirmation) %>
+
diff --git a/app/views/repositories/settings/_vendor_form.html.erb b/app/views/repositories/settings/_vendor_form.html.erb
new file mode 100644
index 00000000000..1d6819f3131
--- /dev/null
+++ b/app/views/repositories/settings/_vendor_form.html.erb
@@ -0,0 +1,44 @@
+<%#-- copyright
+OpenProject is a project management system.
+Copyright (C) 2012-2015 the OpenProject Foundation (OPF)
+
+This program is free software; you can redistribute it and/or
+modify it under the terms of the GNU General Public License version 3.
+
+OpenProject is a fork of ChiliProject, which is a fork of Redmine. The copyright follows:
+Copyright (C) 2006-2013 Jean-Philippe Lang
+Copyright (C) 2010-2013 the ChiliProject Team
+
+This program is free software; you can redistribute it and/or
+modify it under the terms of the GNU General Public License
+as published by the Free Software Foundation; either version 2
+of the License, or (at your option) any later version.
+
+This program is distributed in the hope that it will be useful,
+but WITHOUT ANY WARRANTY; without even the implied warranty of
+MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+GNU General Public License for more details.
+
+You should have received a copy of the GNU General Public License
+along with this program; if not, write to the Free Software
+Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
+
+See doc/COPYRIGHT.rdoc for more details.
+
+++#%>
+<% if repository.new_record? %>
+ <% scm_types = repository.class.available_types %>
+
+<% else %>
+ <%= render partial: "/repositories/settings/vendor_attribute_groups",
+ locals: { existing: true, alone: true, vendor: repository.vendor.underscore,
+ type: repository.scm_type,
+ form: form, repository: repository } %>
+<% end %>
diff --git a/app/views/repositories/settings/git/_local.html.erb b/app/views/repositories/settings/git/_local.html.erb
new file mode 100644
index 00000000000..a5bf7b53bac
--- /dev/null
+++ b/app/views/repositories/settings/git/_local.html.erb
@@ -0,0 +1,45 @@
+<%#-- copyright
+OpenProject is a project management system.
+Copyright (C) 2012-2015 the OpenProject Foundation (OPF)
+
+This program is free software; you can redistribute it and/or
+modify it under the terms of the GNU General Public License version 3.
+
+OpenProject is a fork of ChiliProject, which is a fork of Redmine. The copyright follows:
+Copyright (C) 2006-2013 Jean-Philippe Lang
+Copyright (C) 2010-2013 the ChiliProject Team
+
+This program is free software; you can redistribute it and/or
+modify it under the terms of the GNU General Public License
+as published by the Free Software Foundation; either version 2
+of the License, or (at your option) any later version.
+
+This program is distributed in the hope that it will be useful,
+but WITHOUT ANY WARRANTY; without even the implied warranty of
+MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+GNU General Public License for more details.
+
+You should have received a copy of the GNU General Public License
+along with this program; if not, write to the Free Software
+Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
+
+See doc/COPYRIGHT.rdoc for more details.
+
+++#%>
+
diff --git a/app/views/repositories/settings/git/_managed.html.erb b/app/views/repositories/settings/git/_managed.html.erb
new file mode 100644
index 00000000000..a55c63833e4
--- /dev/null
+++ b/app/views/repositories/settings/git/_managed.html.erb
@@ -0,0 +1,42 @@
+<%#-- copyright
+OpenProject is a project management system.
+Copyright (C) 2012-2015 the OpenProject Foundation (OPF)
+
+This program is free software; you can redistribute it and/or
+modify it under the terms of the GNU General Public License version 3.
+
+OpenProject is a fork of ChiliProject, which is a fork of Redmine. The copyright follows:
+Copyright (C) 2006-2013 Jean-Philippe Lang
+Copyright (C) 2010-2013 the ChiliProject Team
+
+This program is free software; you can redistribute it and/or
+modify it under the terms of the GNU General Public License
+as published by the Free Software Foundation; either version 2
+of the License, or (at your option) any later version.
+
+This program is distributed in the hope that it will be useful,
+but WITHOUT ANY WARRANTY; without even the implied warranty of
+MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+GNU General Public License for more details.
+
+You should have received a copy of the GNU General Public License
+along with this program; if not, write to the Free Software
+Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
+
+See doc/COPYRIGHT.rdoc for more details.
+
+++#%>
+
diff --git a/app/views/repositories/settings/repository_form.js.erb b/app/views/repositories/settings/repository_form.js.erb
new file mode 100644
index 00000000000..a9de0355611
--- /dev/null
+++ b/app/views/repositories/settings/repository_form.js.erb
@@ -0,0 +1,68 @@
+(function($) {
+
+ <% content = render partial: 'repositories/settings' %>
+ $('#tab-content-repository').html('<%= escape_javascript content %>');
+
+ <% unless flash.empty? %>
+ <%# TODO: Double flash from regular flash %>
+ var div_content = $('#content');
+ div_content.find('.flash').remove();
+ div_content.prepend('<%= render_flash_messages %>');
+ <% end %>
+
+ var toggleContent = function(content, selected) {
+ var targetName = '#toggleable-attributes-group--content-' + selected,
+ oldTargets = content.not(targetName),
+ newTarget = $(targetName);
+
+ // would work with fieldset#disabled, but that's bugged up unto IE11
+ // https://connect.microsoft.com/IE/feedbackdetail/view/962368/
+ //
+ // Ugly workaround: disable all inputs manually, but
+ // spare enabling inputs marked with `aria-disabled`
+ oldTargets
+ .slideUp(500)
+ .prop('hidden', true)
+ .find('input,select')
+ .prop('disabled', true);
+
+ newTarget
+ .slideDown(500)
+ .prop('hidden', false)
+ .find('input,select')
+ .not('[aria-disabled="true"]')
+ .prop('disabled', false);
+ };
+
+ $('#tab-content-repository')
+ .find('.attributes-group.-toggleable')
+ .each(function(_i, el) {
+
+ var fs = $(el),
+ name = fs.attr('data-switch'),
+ switches = fs.find('[name="' + name + '"]'),
+ headers = fs.find('.attributes-group--header-text'),
+ content = fs.find('.attributes-group--content.-toggleable');
+
+ // If only one choice, skip setup
+ if (switches.length === 1) {
+ return;
+ }
+
+ // Clickable headers
+ headers.click(function() {
+ var input = $(this).find('input');
+ if (!input.prop('checked')) {
+ input
+ .prop('checked', true)
+ .trigger('change');
+ }
+ });
+
+ // Toggle content
+ switches.on('change', function() {
+ toggleContent(content, this.value);
+ });
+ });
+
+}(jQuery));
diff --git a/app/views/repositories/settings/subversion/_existing.html.erb b/app/views/repositories/settings/subversion/_existing.html.erb
new file mode 100644
index 00000000000..cfe6b326691
--- /dev/null
+++ b/app/views/repositories/settings/subversion/_existing.html.erb
@@ -0,0 +1,63 @@
+<%#-- copyright
+OpenProject is a project management system.
+Copyright (C) 2012-2015 the OpenProject Foundation (OPF)
+
+This program is free software; you can redistribute it and/or
+modify it under the terms of the GNU General Public License version 3.
+
+OpenProject is a fork of ChiliProject, which is a fork of Redmine. The copyright follows:
+Copyright (C) 2006-2013 Jean-Philippe Lang
+Copyright (C) 2010-2013 the ChiliProject Team
+
+This program is free software; you can redistribute it and/or
+modify it under the terms of the GNU General Public License
+as published by the Free Software Foundation; either version 2
+of the License, or (at your option) any later version.
+
+This program is distributed in the hope that it will be useful,
+but WITHOUT ANY WARRANTY; without even the implied warranty of
+MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+GNU General Public License for more details.
+
+You should have received a copy of the GNU General Public License
+along with this program; if not, write to the Free Software
+Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
+
+See doc/COPYRIGHT.rdoc for more details.
+
+++#%>
+
diff --git a/app/views/repositories/settings/subversion/_managed.html.erb b/app/views/repositories/settings/subversion/_managed.html.erb
new file mode 100644
index 00000000000..ad3448f1a3c
--- /dev/null
+++ b/app/views/repositories/settings/subversion/_managed.html.erb
@@ -0,0 +1,41 @@
+<%#-- copyright
+OpenProject is a project management system.
+Copyright (C) 2012-2015 the OpenProject Foundation (OPF)
+
+This program is free software; you can redistribute it and/or
+modify it under the terms of the GNU General Public License version 3.
+
+OpenProject is a fork of ChiliProject, which is a fork of Redmine. The copyright follows:
+Copyright (C) 2006-2013 Jean-Philippe Lang
+Copyright (C) 2010-2013 the ChiliProject Team
+
+This program is free software; you can redistribute it and/or
+modify it under the terms of the GNU General Public License
+as published by the Free Software Foundation; either version 2
+of the License, or (at your option) any later version.
+
+This program is distributed in the hope that it will be useful,
+but WITHOUT ANY WARRANTY; without even the implied warranty of
+MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+GNU General Public License for more details.
+
+You should have received a copy of the GNU General Public License
+along with this program; if not, write to the Free Software
+Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
+
+See doc/COPYRIGHT.rdoc for more details.
+
+++#%>
+
diff --git a/app/views/storage/_required_storage.html.erb b/app/views/storage/_required_storage.html.erb
new file mode 100644
index 00000000000..f00d0e18e98
--- /dev/null
+++ b/app/views/storage/_required_storage.html.erb
@@ -0,0 +1,41 @@
+<%#-- copyright
+OpenProject is a project management system.
+Copyright (C) 2012-2015 the OpenProject Foundation (OPF)
+
+This program is free software; you can redistribute it and/or
+modify it under the terms of the GNU General Public License version 3.
+
+OpenProject is a fork of ChiliProject, which is a fork of Redmine. The copyright follows:
+Copyright (C) 2006-2013 Jean-Philippe Lang
+Copyright (C) 2010-2013 the ChiliProject Team
+
+This program is free software; you can redistribute it and/or
+modify it under the terms of the GNU General Public License
+as published by the Free Software Foundation; either version 2
+of the License, or (at your option) any later version.
+
+This program is distributed in the hope that it will be useful,
+but WITHOUT ANY WARRANTY; without even the implied warranty of
+MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+GNU General Public License for more details.
+
+You should have received a copy of the GNU General Public License
+along with this program; if not, write to the Free Software
+Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
+
+See doc/COPYRIGHT.rdoc for more details.
+
+++#%>
+<% if storage.key?(:total) && storage[:total] > 0 %>
+
+ <%= number_to_human_size(storage[:total], precision: 2) %>
+
+<% end %>
diff --git a/app/views/user_mailer/work_package_added.html.erb b/app/views/user_mailer/work_package_added.html.erb
index a06b6821270..f9073c4e941 100644
--- a/app/views/user_mailer/work_package_added.html.erb
+++ b/app/views/user_mailer/work_package_added.html.erb
@@ -28,5 +28,6 @@ See doc/COPYRIGHT.rdoc for more details.
++#%>
<%= t(:text_work_package_added, :id => "##{@issue.id}", :author => @issue.author) %>
+<%= format_text(@journal.notes, :only_path => false) %>
<%= render :partial => 'issue_details', :locals => { :issue => @issue } %>
diff --git a/app/views/user_mailer/work_package_added.text.erb b/app/views/user_mailer/work_package_added.text.erb
index 6caa89f7af5..456f747da16 100644
--- a/app/views/user_mailer/work_package_added.text.erb
+++ b/app/views/user_mailer/work_package_added.text.erb
@@ -29,5 +29,6 @@ See doc/COPYRIGHT.rdoc for more details.
<%= t(:text_work_package_added, :id => "##{@issue.id}", :author => @issue.author) %>
+<%= @journal.notes if @journal.notes? %>
----------------------------------------
<%= render :partial => 'issue_details', :locals => { :issue => @issue } %>
diff --git a/app/views/work_packages/bulk/edit.html.erb b/app/views/work_packages/bulk/edit.html.erb
index 7fb8c37dfd3..378aa9f3638 100644
--- a/app/views/work_packages/bulk/edit.html.erb
+++ b/app/views/work_packages/bulk/edit.html.erb
@@ -53,7 +53,7 @@ See doc/COPYRIGHT.rdoc for more details.
+ activityNo = activities.length - $index;
+ isInitial = activityNo == 1;">
diff --git a/frontend/app/ui_components/attachment-icon-directive.js b/frontend/app/ui_components/attachment-icon-directive.js
new file mode 100644
index 00000000000..36bb9359b25
--- /dev/null
+++ b/frontend/app/ui_components/attachment-icon-directive.js
@@ -0,0 +1,56 @@
+//-- copyright
+// OpenProject is a project management system.
+// Copyright (C) 2012-2015 the OpenProject Foundation (OPF)
+//
+// This program is free software; you can redistribute it and/or
+// modify it under the terms of the GNU General Public License version 3.
+//
+// OpenProject is a fork of ChiliProject, which is a fork of Redmine. The copyright follows:
+// Copyright (C) 2006-2013 Jean-Philippe Lang
+// Copyright (C) 2010-2013 the ChiliProject Team
+//
+// This program is free software; you can redistribute it and/or
+// modify it under the terms of the GNU General Public License
+// as published by the Free Software Foundation; either version 2
+// of the License, or (at your option) any later version.
+//
+// This program is distributed in the hope that it will be useful,
+// but WITHOUT ANY WARRANTY; without even the implied warranty of
+// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+// GNU General Public License for more details.
+//
+// You should have received a copy of the GNU General Public License
+// along with this program; if not, write to the Free Software
+// Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
+//
+// See doc/COPYRIGHT.rdoc for more details.
+//++
+
+module.exports = function() {
+ var imageRegExp = new RegExp(/^image\/.*$/),
+ excelRegExp = new RegExp(/.*excel$/);
+ return {
+ restrict: 'E',
+ replace: true,
+ scope: {
+ type: '&'
+ },
+ template: '',
+ link: function(scope) {
+ var icon = 'ticket',
+ type = scope.type();
+
+ if (imageRegExp.test(type)) {
+ icon = 'image1';
+ }
+ if (excelRegExp.test(type)) {
+ icon = 'page-xls';
+ }
+ if (type === 'application/pdf') {
+ icon = 'page-pdf';
+ }
+
+ scope.icon = icon;
+ }
+ };
+};
diff --git a/frontend/app/ui_components/index.js b/frontend/app/ui_components/index.js
index 2f137742640..085a5b88fe5 100644
--- a/frontend/app/ui_components/index.js
+++ b/frontend/app/ui_components/index.js
@@ -104,5 +104,9 @@ angular.module('openproject.uiComponents')
.directive('userField', ['PathHelper', require('./user-field-directive')])
.directive('wikiToolbar', [require('./wiki-toolbar-directive')])
.directive('zoomSlider', ['I18n', require('./zoom-slider-directive')])
+ .directive('notifications', [require('./notifications-directive')])
+ .directive('notificationBox', ['I18n', require('./notification-box-directive')])
+ .directive('uploadProgress', [require('./upload-progress-directive')])
+ .directive('attachmentIcon', [require('./attachment-icon-directive')])
.filter('ancestorsExpanded', require('./filters/ancestors-expanded-filter'))
.filter('latestItems', require('./filters/latest-items-filter'));
diff --git a/frontend/app/ui_components/notification-box-directive.js b/frontend/app/ui_components/notification-box-directive.js
new file mode 100644
index 00000000000..53e616d4663
--- /dev/null
+++ b/frontend/app/ui_components/notification-box-directive.js
@@ -0,0 +1,74 @@
+//-- copyright
+// OpenProject is a project management system.
+// Copyright (C) 2012-2015 the OpenProject Foundation (OPF)
+//
+// This program is free software; you can redistribute it and/or
+// modify it under the terms of the GNU General Public License version 3.
+//
+// OpenProject is a fork of ChiliProject, which is a fork of Redmine. The copyright follows:
+// Copyright (C) 2006-2013 Jean-Philippe Lang
+// Copyright (C) 2010-2013 the ChiliProject Team
+//
+// This program is free software; you can redistribute it and/or
+// modify it under the terms of the GNU General Public License
+// as published by the Free Software Foundation; either version 2
+// of the License, or (at your option) any later version.
+//
+// This program is distributed in the hope that it will be useful,
+// but WITHOUT ANY WARRANTY; without even the implied warranty of
+// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+// GNU General Public License for more details.
+//
+// You should have received a copy of the GNU General Public License
+// along with this program; if not, write to the Free Software
+// Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
+//
+// See doc/COPYRIGHT.rdoc for more details.
+//++
+
+module.exports = function(I18n) {
+
+ var notificationBoxController = function(scope) {
+ scope.uploadCount = 0;
+ scope.show = false;
+ scope.I18n = I18n;
+
+ scope.canBeHidden = function() {
+ return scope.content.uploads.length > 5;
+ };
+
+ scope.removable = function() {
+ return scope.content.type !== 'upload';
+ };
+
+ scope.typeable = function() {
+ return !!scope.content.type;
+ };
+
+ scope.remove = function() {
+ if (scope.removable()) {
+ scope.$emit('notification.remove', scope.content);
+ }
+ };
+
+ scope.$on('upload.error', function() {
+ if (scope.content.type === 'upload') {
+ scope.content.type = 'error';
+ }
+ });
+
+ scope.$on('upload.finished', function() {
+ scope.uploadCount += 1;
+ });
+ };
+
+ return {
+ restrict: 'E',
+ replace: true,
+ templateUrl: '/templates/components/notification-box.html',
+ scope: {
+ content: '='
+ },
+ link: notificationBoxController
+ };
+};
diff --git a/frontend/app/ui_components/notifications-directive.js b/frontend/app/ui_components/notifications-directive.js
new file mode 100644
index 00000000000..370c3905ee5
--- /dev/null
+++ b/frontend/app/ui_components/notifications-directive.js
@@ -0,0 +1,52 @@
+//-- copyright
+// OpenProject is a project management system.
+// Copyright (C) 2012-2015 the OpenProject Foundation (OPF)
+//
+// This program is free software; you can redistribute it and/or
+// modify it under the terms of the GNU General Public License version 3.
+//
+// OpenProject is a fork of ChiliProject, which is a fork of Redmine. The copyright follows:
+// Copyright (C) 2006-2013 Jean-Philippe Lang
+// Copyright (C) 2010-2013 the ChiliProject Team
+//
+// This program is free software; you can redistribute it and/or
+// modify it under the terms of the GNU General Public License
+// as published by the Free Software Foundation; either version 2
+// of the License, or (at your option) any later version.
+//
+// This program is distributed in the hope that it will be useful,
+// but WITHOUT ANY WARRANTY; without even the implied warranty of
+// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+// GNU General Public License for more details.
+//
+// You should have received a copy of the GNU General Public License
+// along with this program; if not, write to the Free Software
+// Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
+//
+// See doc/COPYRIGHT.rdoc for more details.
+//++
+
+module.exports = function() {
+
+ var notificationsController = function(scope) {
+ scope.stack = [];
+
+ scope.$on('notification.add', function(_e, notification) {
+ scope.stack.push(notification);
+ });
+
+ scope.$on('notification.remove', function(_e, notification) {
+ _.remove(scope.stack, function(element) {
+ return element === notification;
+ });
+ });
+ };
+
+ return {
+ scope: true,
+ restrict: 'E',
+ replace: true,
+ templateUrl: '/templates/components/notifications.html',
+ link: notificationsController
+ };
+};
diff --git a/frontend/app/ui_components/upload-progress-directive.js b/frontend/app/ui_components/upload-progress-directive.js
new file mode 100644
index 00000000000..0e6718ece87
--- /dev/null
+++ b/frontend/app/ui_components/upload-progress-directive.js
@@ -0,0 +1,59 @@
+//-- copyright
+// OpenProject is a project management system.
+// Copyright (C) 2012-2015 the OpenProject Foundation (OPF)
+//
+// This program is free software; you can redistribute it and/or
+// modify it under the terms of the GNU General Public License version 3.
+//
+// OpenProject is a fork of ChiliProject, which is a fork of Redmine. The copyright follows:
+// Copyright (C) 2006-2013 Jean-Philippe Lang
+// Copyright (C) 2010-2013 the ChiliProject Team
+//
+// This program is free software; you can redistribute it and/or
+// modify it under the terms of the GNU General Public License
+// as published by the Free Software Foundation; either version 2
+// of the License, or (at your option) any later version.
+//
+// This program is distributed in the hope that it will be useful,
+// but WITHOUT ANY WARRANTY; without even the implied warranty of
+// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+// GNU General Public License for more details.
+//
+// You should have received a copy of the GNU General Public License
+// along with this program; if not, write to the Free Software
+// Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
+//
+// See doc/COPYRIGHT.rdoc for more details.
+//++
+
+module.exports = function() {
+ 'use strict';
+ var uploadProgressController = function(scope) {
+
+ scope.upload.progress(function(details) {
+ scope.file = details.config.file.name;
+ if (details.lengthComputable) {
+ scope.value = Math.round(details.loaded / details.total * 100);
+ } else {
+ // dummy value if not computable
+ scope.value = 10;
+ }
+ }).success(function() {
+ scope.value = 100;
+ scope.completed = true;
+ scope.$emit('upload.finished');
+ }).error(function() {
+ scope.error = true;
+ scope.$emit('upload.error');
+ });
+ };
+
+ return {
+ scope: {
+ upload: '='
+ },
+ link: uploadProgressController,
+ replace: true,
+ templateUrl: '/templates/components/upload-progress.html'
+ };
+};
diff --git a/frontend/app/work_packages/controllers/details-tab-overview-controller.js b/frontend/app/work_packages/controllers/details-tab-overview-controller.js
index afd11440928..fe6f81607e8 100644
--- a/frontend/app/work_packages/controllers/details-tab-overview-controller.js
+++ b/frontend/app/work_packages/controllers/details-tab-overview-controller.js
@@ -31,7 +31,9 @@ module.exports = function(
WorkPackagesOverviewService,
WorkPackageFieldService,
EditableFieldsState,
- WorkPackageDisplayHelper
+ WorkPackageDisplayHelper,
+ NotificationsService,
+ I18n
) {
var vm = this;
@@ -77,5 +79,8 @@ module.exports = function(
return left.localeCompare(right);
});
});
+ $scope.$on('workPackageUpdatedInEditor', function() {
+ NotificationsService.addSuccess(I18n.t('js.label_successful_update'));
+ });
}
};
diff --git a/frontend/app/work_packages/controllers/index.js b/frontend/app/work_packages/controllers/index.js
index f1034b65043..8aa8221663a 100644
--- a/frontend/app/work_packages/controllers/index.js
+++ b/frontend/app/work_packages/controllers/index.js
@@ -41,6 +41,8 @@ angular.module('openproject.workPackages.controllers')
'WorkPackageFieldService',
'EditableFieldsState',
'WorkPackagesDisplayHelper',
+ 'NotificationsService',
+ 'I18n',
require('./details-tab-overview-controller')
])
.constant('ADD_WATCHER_SELECT_INDEX', -1)
diff --git a/frontend/app/work_packages/controllers/work-package-details-controller.js b/frontend/app/work_packages/controllers/work-package-details-controller.js
index 33788d337d3..b8a13789ca8 100644
--- a/frontend/app/work_packages/controllers/work-package-details-controller.js
+++ b/frontend/app/work_packages/controllers/work-package-details-controller.js
@@ -174,8 +174,7 @@ module.exports = function($scope,
function displayedActivities(workPackage) {
var activities = workPackage.embedded.activities;
- // remove first activity (assumes activities are sorted chronologically)
- activities.splice(0, 1);
+
if ($scope.activitiesSortedInDescendingOrder) {
activities.reverse();
}
diff --git a/frontend/app/work_packages/directives/index.js b/frontend/app/work_packages/directives/index.js
index 63584c6f3b3..fb83a6e7973 100644
--- a/frontend/app/work_packages/directives/index.js
+++ b/frontend/app/work_packages/directives/index.js
@@ -94,6 +94,14 @@ angular.module('openproject.workPackages.directives')
'featureFlags',
'PathHelper',
require('./work-packages-table-directive')
+ ])
+ .directive('workPackageAttachments', [
+ 'WorkPackageAttachmentsService',
+ 'NotificationsService',
+ 'I18n',
+ 'ConfigurationService',
+ 'ConversionService',
+ require('./work-package-attachments-directive')
]);
require('./inplace_editor');
diff --git a/frontend/app/work_packages/directives/inplace_editor/inplace-editor-edit-pane-directive.js b/frontend/app/work_packages/directives/inplace_editor/inplace-editor-edit-pane-directive.js
index c78b1c86e40..0f9ec3a5a2d 100644
--- a/frontend/app/work_packages/directives/inplace_editor/inplace-editor-edit-pane-directive.js
+++ b/frontend/app/work_packages/directives/inplace_editor/inplace-editor-edit-pane-directive.js
@@ -31,7 +31,8 @@ module.exports = function(
EditableFieldsState,
FocusHelper,
$timeout,
- ApiHelper) {
+ ApiHelper,
+ $rootScope) {
return {
transclude: true,
replace: true,
@@ -43,6 +44,11 @@ module.exports = function(
var vm = this;
var acknowledgedValidationErrors = ['required', 'number'];
+ // go full retard
+ var uploadPendingAttachments = function(wp) {
+ $rootScope.$broadcast('uploadPendingAttachments', wp);
+ };
+
this.submit = function(notify) {
var fieldController = $scope.fieldController;
var pendingFormChanges = getPendingFormChanges();
@@ -82,6 +88,7 @@ module.exports = function(
EditableFieldsState.errors = null;
}
);
+ uploadPendingAttachments(updatedWorkPackage);
})).catch(setFailure);
} else {
afterError();
diff --git a/frontend/app/work_packages/directives/work-package-attachments-directive.js b/frontend/app/work_packages/directives/work-package-attachments-directive.js
new file mode 100644
index 00000000000..ef002350b60
--- /dev/null
+++ b/frontend/app/work_packages/directives/work-package-attachments-directive.js
@@ -0,0 +1,128 @@
+//-- copyright
+// OpenProject is a project management system.
+// Copyright (C) 2012-2015 the OpenProject Foundation (OPF)
+//
+// This program is free software; you can redistribute it and/or
+// modify it under the terms of the GNU General Public License version 3.
+//
+// OpenProject is a fork of ChiliProject, which is a fork of Redmine. The copyright follows:
+// Copyright (C) 2006-2013 Jean-Philippe Lang
+// Copyright (C) 2010-2013 the ChiliProject Team
+//
+// This program is free software; you can redistribute it and/or
+// modify it under the terms of the GNU General Public License
+// as published by the Free Software Foundation; either version 2
+// of the License, or (at your option) any later version.
+//
+// This program is distributed in the hope that it will be useful,
+// but WITHOUT ANY WARRANTY; without even the implied warranty of
+// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+// GNU General Public License for more details.
+//
+// You should have received a copy of the GNU General Public License
+// along with this program; if not, write to the Free Software
+// Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
+//
+// See doc/COPYRIGHT.rdoc for more details.
+//++
+
+module.exports = function(
+ workPackageAttachmentsService,
+ NotificationsService,
+ I18n,
+ ConfigurationService,
+ ConversionService
+) {
+ 'use strict';
+ var editMode = function(attrs) {
+ return typeof attrs.edit !== 'undefined';
+ };
+
+ var attachmentsController = function(scope, element, attrs) {
+ scope.files = [];
+
+ var workPackage = scope.workPackage(),
+ upload = function(event, workPackage) {
+ if (scope.files.length > 0) {
+ workPackageAttachmentsService.upload(workPackage, scope.files).then(function() {
+ scope.files = [];
+ loadAttachments();
+ });
+ }
+ },
+ loadAttachments = function() {
+ if (!editMode(attrs)) {
+ return;
+ }
+ scope.loading = true;
+ workPackageAttachmentsService.load(workPackage).then(function(attachments) {
+ scope.attachments = attachments;
+ }).finally(function() {
+ scope.loading = false;
+ });
+ };
+
+ scope.I18n = I18n;
+ scope.rejectedFiles = [];
+ scope.size = ConversionService.fileSize;
+
+ scope.instantUpload = function() {
+ scope.$emit('uploadPendingAttachments', workPackage);
+ };
+
+ var currentlyRemoving = [];
+ scope.remove = function(file) {
+ currentlyRemoving.push(file);
+ workPackageAttachmentsService.remove(file).then(function(file) {
+ _.remove(scope.attachments, file);
+ _.remove(scope.files, file);
+ }).finally(function() {
+ _.remove(currentlyRemoving, file);
+ });
+ };
+
+ scope.deleting = function(attachment) {
+ return _.findIndex(currentlyRemoving, attachment) > -1;
+ };
+
+ scope.$on('uploadPendingAttachments', upload);
+ scope.$watch('rejectedFiles', function(rejectedFiles) {
+ if (rejectedFiles.length === 0) {
+ return;
+ }
+ var errors = _.map(rejectedFiles, function(file) {
+ return file.name + ' (' + scope.size(file.size) + ')';
+ }),
+ message = I18n.t('js.label_rejected_files_reason',
+ { maximumFilesize: scope.size(scope.maximumFileSize) }
+ );
+ NotificationsService.addError(message, errors);
+ });
+
+ scope.fetchingConfiguration = true;
+ ConfigurationService.api().then(function(settings) {
+ scope.maximumFileSize = settings.maximumAttachmentFileSize;
+ // somehow, I18n cannot interpolate function results, so we need to cache this once
+ scope.maxFileSize = scope.size(settings.maximumAttachmentFileSize);
+ scope.fetchingConfiguration = false;
+ });
+
+ loadAttachments();
+ };
+
+ return {
+ restrict: 'E',
+ replace: true,
+ reqire: '^workPackageField',
+ scope: {
+ workPackage: '&'
+ },
+ templateUrl: function(element, attrs) {
+ if (editMode(attrs)) {
+ return '/templates/work_packages/attachments-edit.html';
+ }
+ return '/templates/work_packages/attachments.html';
+ },
+ link: attachmentsController
+ };
+};
diff --git a/frontend/app/work_packages/services/index.js b/frontend/app/work_packages/services/index.js
index 7308e10d16b..1663f14be3f 100644
--- a/frontend/app/work_packages/services/index.js
+++ b/frontend/app/work_packages/services/index.js
@@ -70,4 +70,14 @@ angular.module('openproject.workPackages.services')
])
.service('EditableFieldsState',
require('./editable-fields-state')
- );
+ )
+ .service('WorkPackageAttachmentsService', [
+ 'Upload', // 'Upload' is provided by ngFileUpload
+ 'PathHelper',
+ 'I18n',
+ 'NotificationsService',
+ '$q',
+ '$timeout',
+ '$http',
+ require('./work-package-attachments-service')
+ ]);
diff --git a/frontend/app/work_packages/services/work-package-attachments-service.js b/frontend/app/work_packages/services/work-package-attachments-service.js
new file mode 100644
index 00000000000..6201a677273
--- /dev/null
+++ b/frontend/app/work_packages/services/work-package-attachments-service.js
@@ -0,0 +1,97 @@
+//-- copyright
+// OpenProject is a project management system.
+// Copyright (C) 2012-2015 the OpenProject Foundation (OPF)
+//
+// This program is free software; you can redistribute it and/or
+// modify it under the terms of the GNU General Public License version 3.
+//
+// OpenProject is a fork of ChiliProject, which is a fork of Redmine. The copyright follows:
+// Copyright (C) 2006-2013 Jean-Philippe Lang
+// Copyright (C) 2010-2013 the ChiliProject Team
+//
+// This program is free software; you can redistribute it and/or
+// modify it under the terms of the GNU General Public License
+// as published by the Free Software Foundation; either version 2
+// of the License, or (at your option) any later version.
+//
+// This program is distributed in the hope that it will be useful,
+// but WITHOUT ANY WARRANTY; without even the implied warranty of
+// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+// GNU General Public License for more details.
+//
+// You should have received a copy of the GNU General Public License
+// along with this program; if not, write to the Free Software
+// Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
+//
+// See doc/COPYRIGHT.rdoc for more details.
+//++
+
+module.exports = function(Upload, PathHelper, I18n, NotificationsService, $q, $timeout, $http) {
+
+ var upload = function(workPackage, files) {
+ var uploadPath = workPackage.links.addAttachment.url();
+ // for file in files build some promises, create a notification per WP,
+ // notify the noticiation (wat?) about progress
+ var uploads = _.map(files, function(file) {
+ var options = {
+ url: uploadPath,
+ fields: {
+ metadata: {
+ fileName: file.name,
+ description: file.description
+ }
+ },
+ file: file
+ };
+ return Upload.upload(options);
+ });
+
+ // notify the user
+ var message = I18n.t('js.label_upload_notification', {
+ id: workPackage.props.id,
+ subject: workPackage.props.subject
+ });
+
+ var notification = NotificationsService.addWorkPackageUpload(message, uploads);
+ var allUploadsDone = $q.defer();
+ $q.all(uploads).then(function() {
+ $timeout(function() { // let the notification linger for a bit
+ NotificationsService.remove(notification);
+ allUploadsDone.resolve();
+ }, 700);
+ }, function(err) {
+ allUploadsDone.reject(err);
+ });
+ return allUploadsDone.promise;
+ },
+ load = function(workPackage) {
+ var path = workPackage.links.attachments.url(),
+ attachments = $q.defer();
+ $http.get(path).success(function(response) {
+ attachments.resolve(response._embedded.elements);
+ }).error(function(err) {
+ attachments.reject(err);
+ });
+ return attachments.promise;
+ },
+ remove = function(fileOrAttachment) {
+ var removal = $q.defer();
+ if (angular.isObject(fileOrAttachment._links)) {
+ var path = fileOrAttachment._links.self.href;
+ $http.delete(path).success(function() {
+ removal.resolve(fileOrAttachment);
+ }).error(function(err) {
+ removal.reject(err);
+ });
+ } else {
+ removal.resolve(fileOrAttachment);
+ }
+ return removal.promise;
+ };
+
+ return {
+ upload: upload,
+ remove: remove,
+ load: load
+ };
+};
diff --git a/frontend/app/work_packages/tabs/user-activity-directive.js b/frontend/app/work_packages/tabs/user-activity-directive.js
index 953807bb9ae..6927f7faacb 100644
--- a/frontend/app/work_packages/tabs/user-activity-directive.js
+++ b/frontend/app/work_packages/tabs/user-activity-directive.js
@@ -45,6 +45,7 @@ module.exports = function($uiViewScroll,
workPackage: '=',
activity: '=',
activityNo: '=',
+ isInitial: '=',
inputElementId: '='
},
link: function(scope, element, attrs, exclusiveEditController) {
diff --git a/frontend/bower.json b/frontend/bower.json
index 4e8365ab731..ba0b3f0ba5a 100644
--- a/frontend/bower.json
+++ b/frontend/bower.json
@@ -27,7 +27,8 @@
"foundation-apps": "1.1.0",
"bourbon": "~4.2.1",
"ui-select": "0xf013/ui-select#c7bef79e24cbeab977635c7a94d8f9504d4ee2e2",
- "mousetrap": "~1.4.6"
+ "mousetrap": "~1.4.6",
+ "ng-file-upload": "~5.0.9"
},
"devDependencies": {
"mocha": "~1.14.0",
diff --git a/frontend/karma.conf.js b/frontend/karma.conf.js
index 4b9962d00ea..4c5c5bab613 100644
--- a/frontend/karma.conf.js
+++ b/frontend/karma.conf.js
@@ -84,9 +84,13 @@ module.exports = function(config) {
// preprocess matching files before serving them to the browser
// available preprocessors: https://npmjs.org/browse/keyword/karma-preprocessor
preprocessors: {
+ '/templates/**/*.html': ['ng-html2js'],
'../app/assets/javascripts/*.js': ['coverage'],
'app/**/*.js': ['webpack'] // coverage disabled
},
+ ngHtml2JsPreprocessor: {
+ module: 'openproject.templates'
+ },
// test results reporter to use
diff --git a/frontend/package.json b/frontend/package.json
index cb76f8af3c7..b417b0eb554 100644
--- a/frontend/package.json
+++ b/frontend/package.json
@@ -29,6 +29,7 @@
"karma-firefox-launcher": "~0.1.3",
"karma-junit-reporter": "~0.2.2",
"karma-mocha": "~0.1.3",
+ "karma-ng-html2js-preprocessor": "^0.1.2",
"karma-phantomjs-launcher": "~0.1.4",
"karma-webpack": "^1.5.0",
"mocha": "~1.18.2",
diff --git a/frontend/tests/integration/specs/work-packages/details-pane/details-pane-show-hide-accessibility-spec.js b/frontend/tests/integration/specs/work-packages/details-pane/details-pane-show-hide-accessibility-spec.js
index 08795ae8ae2..74039fc725c 100644
--- a/frontend/tests/integration/specs/work-packages/details-pane/details-pane-show-hide-accessibility-spec.js
+++ b/frontend/tests/integration/specs/work-packages/details-pane/details-pane-show-hide-accessibility-spec.js
@@ -38,12 +38,20 @@ describe('OpenProject', function () {
describe('...', function () {
beforeEach(function () {
$('.attributes-group.ng-scope:nth-child(1) .inplace-edit--read-value').click();
+ //element(by.css('.attributes-group.ng-scope:nth-child(1) textarea'));
+
+ // tab through all the elements in the inplace edit control
+ // Save
+ browser.actions().sendKeys(protractor.Key.TAB).perform();
+ // Save and send
+ browser.actions().sendKeys(protractor.Key.TAB).perform();
+ // Cancel
+ browser.actions().sendKeys(protractor.Key.TAB).perform();
});
+
it('show all / hide all should be accessible in one tab', function () {
- var editableTextarea = element(by.css('.attributes-group.ng-scope:nth-child(1) textarea'));
- editableTextarea.sendKeys(protractor.Key.ESCAPE);
- browser.actions().sendKeys(protractor.Key.TAB).perform();
browser.actions().sendKeys(protractor.Key.TAB).perform();
+
return expect(browser.driver.switchTo().activeElement().getText())
.to.eventually.equal('Show all');
});
diff --git a/frontend/tests/integration/specs/work-packages/details-pane/details-pane-spec.js b/frontend/tests/integration/specs/work-packages/details-pane/details-pane-spec.js
index af516ae60ad..e96aece7bf3 100644
--- a/frontend/tests/integration/specs/work-packages/details-pane/details-pane-spec.js
+++ b/frontend/tests/integration/specs/work-packages/details-pane/details-pane-spec.js
@@ -72,15 +72,15 @@ describe('OpenProject', function() {
it('should render the last 3 activites', function() {
expect(
$('ul li:nth-child(1) div.comments-number').getText()
- ).to.eventually.equal('#3');
+ ).to.eventually.equal('#4');
expect(
$('ul li:nth-child(2) div.comments-number').getText()
- ).to.eventually.equal('#2');
+ ).to.eventually.equal('#3');
expect(
$('ul li:nth-child(3) div.comments-number').getText()
- ).to.eventually.equal('#1');
+ ).to.eventually.equal('#2');
});
it('should contain the activities details', function() {
diff --git a/frontend/tests/integration/specs/work-packages/work-packages-spec.js b/frontend/tests/integration/specs/work-packages/work-packages-spec.js
index d641f444c9a..f7a23e90399 100644
--- a/frontend/tests/integration/specs/work-packages/work-packages-spec.js
+++ b/frontend/tests/integration/specs/work-packages/work-packages-spec.js
@@ -34,30 +34,25 @@ describe('OpenProject', function() {
beforeEach(function() {
page.get();
- browser.waitForAngular();
});
it('should show work packages title', function() {
- page.get();
-
expect(page.getSelectableTitle().getText()).to.eventually.equal('Work packages');
});
- it('should show work packages', function() {
- page.get();
+ it('should show the default column headers', function() {
+ var expected = ['', //note that there is hidden text
+ 'ID',
+ 'TYPE',
+ 'STATUS',
+ 'SUBJECT',
+ 'ASSIGNEE'];
- page.getTableHeaders().map(function(heading) {
- return heading.getText();
- }).then(function(headingTexts) {
- var expected = ['',
- 'ID',
- 'TYPE',
- 'STATUS',
- 'SUBJECT',
- 'ASSIGNEE'];
-
- for (var i = 0; i < expected.length; i++) {
- expect(headingTexts[i]).to.equal(expected[i]);
+ // the $$('a') should be unnecessary but it was added to prevent flickering
+ // runs caused by .hidden-for-sighted elements being visible when they shouldn't.
+ page.getTableHeaders().$$('a').then(function(headings) {
+ for (var i = 0; i < headings.length; i++) {
+ expect(headings[i].getText()).to.eventually.equal(expected[i]);
}
});
});
diff --git a/frontend/tests/unit/tests/directives/components/notification-box-directive-test.js b/frontend/tests/unit/tests/directives/components/notification-box-directive-test.js
new file mode 100644
index 00000000000..3d83ff641d8
--- /dev/null
+++ b/frontend/tests/unit/tests/directives/components/notification-box-directive-test.js
@@ -0,0 +1,63 @@
+//-- copyright
+// OpenProject is a project management system.
+// Copyright (C) 2012-2015 the OpenProject Foundation (OPF)
+//
+// This program is free software; you can redistribute it and/or
+// modify it under the terms of the GNU General Public License version 3.
+//
+// OpenProject is a fork of ChiliProject, which is a fork of Redmine. The copyright follows:
+// Copyright (C) 2006-2013 Jean-Philippe Lang
+// Copyright (C) 2010-2013 the ChiliProject Team
+//
+// This program is free software; you can redistribute it and/or
+// modify it under the terms of the GNU General Public License
+// as published by the Free Software Foundation; either version 2
+// of the License, or (at your option) any later version.
+//
+// This program is distributed in the hope that it will be useful,
+// but WITHOUT ANY WARRANTY; without even the implied warranty of
+// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+// GNU General Public License for more details.
+//
+// You should have received a copy of the GNU General Public License
+// along with this program; if not, write to the Free Software
+// Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
+//
+// See doc/COPYRIGHT.rdoc for more details.
+/* jshint expr: true */
+//++
+
+describe('NotificationBoxDirective', function() {
+ 'use strict';
+ var $compile, $rootScope;
+
+ beforeEach(module('openproject.uiComponents'));
+ beforeEach(module('openproject.templates')); // see karmaConfig
+
+ beforeEach(inject(function(_$compile_, _$rootScope_) {
+ $compile = _$compile_;
+ $rootScope = _$rootScope_;
+ }));
+
+ it('should need a content to properly work', function() {
+ expect(function() {
+ $compile('')($rootScope);
+ $rootScope.$digest();
+ }).to.throw;
+ });
+
+ it('should render with content set', function() {
+ $rootScope.warning = { message: 'warning!' };
+ var element = $compile('')($rootScope);
+ $rootScope.$digest();
+ expect(element.html()).to.contain('warning!');
+ });
+
+ it('should render with the appropiate type', function() {
+ $rootScope.error = { message: 'error!', type: 'error' };
+ var element = $compile('')($rootScope);
+ $rootScope.$digest();
+ expect(element.html()).to.contain('-error');
+ });
+
+});
diff --git a/frontend/tests/unit/tests/directives/components/notifications-directive-test.js b/frontend/tests/unit/tests/directives/components/notifications-directive-test.js
new file mode 100644
index 00000000000..4b46c04c699
--- /dev/null
+++ b/frontend/tests/unit/tests/directives/components/notifications-directive-test.js
@@ -0,0 +1,71 @@
+//-- copyright
+// OpenProject is a project management system.
+// Copyright (C) 2012-2015 the OpenProject Foundation (OPF)
+//
+// This program is free software; you can redistribute it and/or
+// modify it under the terms of the GNU General Public License version 3.
+//
+// OpenProject is a fork of ChiliProject, which is a fork of Redmine. The copyright follows:
+// Copyright (C) 2006-2013 Jean-Philippe Lang
+// Copyright (C) 2010-2013 the ChiliProject Team
+//
+// This program is free software; you can redistribute it and/or
+// modify it under the terms of the GNU General Public License
+// as published by the Free Software Foundation; either version 2
+// of the License, or (at your option) any later version.
+//
+// This program is distributed in the hope that it will be useful,
+// but WITHOUT ANY WARRANTY; without even the implied warranty of
+// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+// GNU General Public License for more details.
+//
+// You should have received a copy of the GNU General Public License
+// along with this program; if not, write to the Free Software
+// Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
+//
+// See doc/COPYRIGHT.rdoc for more details.
+/* jshint expr: true */
+//++
+
+describe('NotificationsDirective', function() {
+ 'use strict';
+ var $compile, $rootScope;
+
+ beforeEach(module('openproject.uiComponents'));
+ beforeEach(module('openproject.templates')); // see karmaConfig
+
+ beforeEach(inject(function(_$compile_, _$rootScope_) {
+ $compile = _$compile_;
+ $rootScope = _$rootScope_;
+ }));
+
+ it('should replace the notifications element with the notifications frame', function() {
+
+ var element = $compile('')($rootScope);
+
+ $rootScope.$digest();
+
+ expect(element.html()).to.include('
');
+ });
+
+ context('w/ notifications present', function() {
+ var notification = { message: 'message' };
+
+ it('should be able to receive notification via $broadcast', function() {
+ var element = $compile('')($rootScope);
+ $rootScope.$digest();
+ $rootScope.$broadcast('notification.add', notification);
+ expect(element.scope().stack).to.contain(notification);
+ });
+
+ it('should remove notifications when called for', function() {
+ var element = $compile('')($rootScope);
+ $rootScope.$digest();
+ expect(element.scope().stack).to.be.empty;
+ $rootScope.$broadcast('notification.add', notification);
+ expect(element.scope().stack).to.contain(notification);
+ $rootScope.$broadcast('notification.remove', notification);
+ expect(element.scope().stack).to.be.empty;
+ });
+ });
+});
diff --git a/frontend/tests/unit/tests/services/conversion-service-test.js b/frontend/tests/unit/tests/services/conversion-service-test.js
new file mode 100644
index 00000000000..d6687d3fbc9
--- /dev/null
+++ b/frontend/tests/unit/tests/services/conversion-service-test.js
@@ -0,0 +1,62 @@
+//-- copyright
+// OpenProject is a project management system.
+// Copyright (C) 2012-2015 the OpenProject Foundation (OPF)
+//
+// This program is free software; you can redistribute it and/or
+// modify it under the terms of the GNU General Public License version 3.
+//
+// OpenProject is a fork of ChiliProject, which is a fork of Redmine. The copyright follows:
+// Copyright (C) 2006-2013 Jean-Philippe Lang
+// Copyright (C) 2010-2013 the ChiliProject Team
+//
+// This program is free software; you can redistribute it and/or
+// modify it under the terms of the GNU General Public License
+// as published by the Free Software Foundation; either version 2
+// of the License, or (at your option) any later version.
+//
+// This program is distributed in the hope that it will be useful,
+// but WITHOUT ANY WARRANTY; without even the implied warranty of
+// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+// GNU General Public License for more details.
+//
+// You should have received a copy of the GNU General Public License
+// along with this program; if not, write to the Free Software
+// Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
+//
+// See doc/COPYRIGHT.rdoc for more details.
+//++
+
+describe('ConversionService', function() {
+ 'use strict';
+ var ConversionService;
+
+ beforeEach(module('openproject.services'));
+
+ beforeEach(inject(function(_ConversionService_){
+ ConversionService = _ConversionService_;
+ }));
+
+ it('be able to turn bytes into KiloBytes', function() {
+ var kiloBytes = ConversionService.kilobytes(1000);
+ expect(kiloBytes).to.eql(1);
+ });
+
+ it('be able to turn bytes into MegaBytes', function() {
+ var megabytes = ConversionService.megabytes(1000000);
+ expect(megabytes).to.eql(1);
+ });
+
+ it('should dynamically convert bytes into Mega- and Kilobytes', function() {
+ var result = ConversionService.fileSize(1000000);
+ expect(result).to.eql('1MB');
+
+ result = ConversionService.fileSize(1000);
+ expect(result).to.eql('1kB');
+
+ result = ConversionService.fileSize(1234);
+ expect(result).to.eql('1.2kB');
+
+ result = ConversionService.fileSize(1874234);
+ expect(result).to.eql('1.9MB');
+ });
+});
diff --git a/frontend/tests/unit/tests/services/notifications-service-test.js b/frontend/tests/unit/tests/services/notifications-service-test.js
new file mode 100644
index 00000000000..f8de32619c4
--- /dev/null
+++ b/frontend/tests/unit/tests/services/notifications-service-test.js
@@ -0,0 +1,80 @@
+//-- copyright
+// OpenProject is a project management system.
+// Copyright (C) 2012-2015 the OpenProject Foundation (OPF)
+//
+// This program is free software; you can redistribute it and/or
+// modify it under the terms of the GNU General Public License version 3.
+//
+// OpenProject is a fork of ChiliProject, which is a fork of Redmine. The copyright follows:
+// Copyright (C) 2006-2013 Jean-Philippe Lang
+// Copyright (C) 2010-2013 the ChiliProject Team
+//
+// This program is free software; you can redistribute it and/or
+// modify it under the terms of the GNU General Public License
+// as published by the Free Software Foundation; either version 2
+// of the License, or (at your option) any later version.
+//
+// This program is distributed in the hope that it will be useful,
+// but WITHOUT ANY WARRANTY; without even the implied warranty of
+// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+// GNU General Public License for more details.
+//
+// You should have received a copy of the GNU General Public License
+// along with this program; if not, write to the Free Software
+// Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
+//
+// See doc/COPYRIGHT.rdoc for more details.
+//++
+
+describe('NotificationsService', function() {
+ 'use strict';
+ var NotificationsService;
+
+ beforeEach(module('openproject.services'));
+
+ beforeEach(inject(function(_NotificationsService_){
+ NotificationsService = _NotificationsService_;
+ }));
+
+ it('should be able to create notifications', function() {
+ var notification = NotificationsService.add('message');
+
+ expect(notification).to.eql({ message: 'message' });
+ });
+
+ it('should be able to create warnings', function() {
+ var notification = NotificationsService.addWarning('warning!');
+
+ expect(notification).to.eql({ message: 'warning!', type: 'warning' });
+ });
+
+ it('should throw an Error if trying to create an error without errors', function() {
+ expect(function() {
+ NotificationsService.addError('error!');
+ }).to.throw(Error);
+ });
+
+ it('should throw an Error if trying to create an upload without uploads', function() {
+ expect(function() {
+ NotificationsService.addWorkPackageUpload('themUploads');
+ }).to.throw(Error);
+ });
+
+ it('should be able to create error messages with errors', function() {
+ var notification = NotificationsService.addError('a super cereal error', ['fooo', 'baarr']);
+ expect(notification).to.eql({
+ message: 'a super cereal error',
+ errors: ['fooo', 'baarr'],
+ type: 'error'
+ });
+ });
+
+ it('should be able to create error messages with errors', function() {
+ var notification = NotificationsService.addWorkPackageUpload('uploading...', [0, 1, 2]);
+ expect(notification).to.eql({
+ message: 'uploading...',
+ type: 'upload',
+ uploads: [0, 1, 2]
+ });
+ });
+});
diff --git a/frontend/tests/unit/tests/work_packages/services/work-package-attachments-service-test.js b/frontend/tests/unit/tests/work_packages/services/work-package-attachments-service-test.js
new file mode 100644
index 00000000000..dddb5d94ec1
--- /dev/null
+++ b/frontend/tests/unit/tests/work_packages/services/work-package-attachments-service-test.js
@@ -0,0 +1,127 @@
+//-- copyright
+// OpenProject is a project management system.
+// Copyright (C) 2012-2015 the OpenProject Foundation (OPF)
+//
+// This program is free software; you can redistribute it and/or
+// modify it under the terms of the GNU General Public License version 3.
+//
+// OpenProject is a fork of ChiliProject, which is a fork of Redmine. The copyright follows:
+// Copyright (C) 2006-2013 Jean-Philippe Lang
+// Copyright (C) 2010-2013 the ChiliProject Team
+//
+// This program is free software; you can redistribute it and/or
+// modify it under the terms of the GNU General Public License
+// as published by the Free Software Foundation; either version 2
+// of the License, or (at your option) any later version.
+//
+// This program is distributed in the hope that it will be useful,
+// but WITHOUT ANY WARRANTY; without even the implied warranty of
+// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+// GNU General Public License for more details.
+//
+// You should have received a copy of the GNU General Public License
+// along with this program; if not, write to the Free Software
+// Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
+//
+// See doc/COPYRIGHT.rdoc for more details.
+//++
+/* jshint expr: true */
+/* globals WebKitBlobBuilder */
+
+describe('workPackageAttachmentsService', function() {
+ 'use strict';
+ var WorkPackageAttachmentsService, $httpBackend;
+
+ // mock me a work package
+ // TODO: remove that hyperagent.js nonsense asap
+ var workPackage = {
+ props: {
+ id: 1
+ },
+ links: {
+ attachments: {
+ url: function() {
+ return '/api/v3/work_packages/1/attachments';
+ }
+ },
+ addAttachment: {
+ url: function() {
+ return '/api/v3/work_packages/1/attachments';
+ }
+ }
+ }
+ };
+
+ // mock me an attachment
+ var attachment = {
+ _links: {
+ self: {
+ href: '/attachments/1234'
+ }
+ }
+ };
+
+ beforeEach(module('openproject.workPackages'));
+
+ beforeEach(inject(function(_WorkPackageAttachmentsService_, _$httpBackend_){
+ WorkPackageAttachmentsService = _WorkPackageAttachmentsService_;
+ $httpBackend = _$httpBackend_;
+ }));
+
+ afterEach(function() {
+ $httpBackend.verifyNoOutstandingRequest();
+ $httpBackend.verifyNoOutstandingExpectation();
+ });
+
+ describe('loading attachments', function() {
+ beforeEach(function() {
+ $httpBackend.expectGET('/api/v3/work_packages/1/attachments').respond({
+ _embedded: {
+ elements: [1,2,3]
+ }
+ });
+ });
+
+ it('should retrieve attachments for a given work pacakge', function () {
+ WorkPackageAttachmentsService.load(workPackage).then(function(result) {
+ expect(result).to.eql([1,2,3]);
+ });
+ $httpBackend.flush();
+ });
+ });
+
+ describe('creating an attachment', function() {
+ beforeEach(function() {
+ $httpBackend.expectPOST('/api/v3/work_packages/1/attachments').respond({});
+ });
+
+ function createFiles() {
+ var blob;
+ try {
+ var builder = new WebKitBlobBuilder();
+ builder.append(['I am a TestFile for WebKit browsers']);
+ blob = builder.getBlob();
+ } catch(Error) {
+ blob = new Blob(['I am a testfile']);
+ }
+ return [blob];
+ }
+
+ it('should create an attachment for a given work package', function () {
+ var files = createFiles();
+ WorkPackageAttachmentsService.upload(workPackage, files);
+ $httpBackend.flush();
+ });
+ });
+
+ describe('deleting an attachment', function() {
+ beforeEach(function() {
+ $httpBackend.expectDELETE('/attachments/1234').respond({});
+ });
+
+ it('should remove an attachment', function () {
+ WorkPackageAttachmentsService.remove(attachment);
+ $httpBackend.flush();
+ });
+ });
+});
diff --git a/frontend/tests/unit/tests/work_packages/tabs/user-activity-directive-test.js b/frontend/tests/unit/tests/work_packages/tabs/user-activity-directive-test.js
index 44e7f30b22e..7524139e499 100644
--- a/frontend/tests/unit/tests/work_packages/tabs/user-activity-directive-test.js
+++ b/frontend/tests/unit/tests/work_packages/tabs/user-activity-directive-test.js
@@ -49,7 +49,7 @@ describe('userActivity Directive', function() {
beforeEach(inject(function($rootScope, $compile, $uiViewScroll, $timeout, $location, I18n, PathHelper, ActivityService, UsersHelper) {
var html;
- html = '
';
+ html = '
';
rootScope = $rootScope;
scope = $rootScope.$new();
@@ -107,8 +107,9 @@ describe('userActivity Directive', function() {
html: 'Type changed'
},
]
- },
+ }
};
+ scope.isInitial = false;
compile();
});
@@ -164,6 +165,18 @@ describe('userActivity Directive', function() {
expect(detail1).to.eq(scope.activity.props.details[0].html);
expect(detail2).to.eq(scope.activity.props.details[1].html);
});
+
+ context('for initial journal', function() {
+ beforeEach(function() {
+ scope.isInitial = true;
+ compile();
+ });
+ it('should not render activity details', function() {
+ var listFinder = element.find('ul.work-package-details-activities-messages');
+
+ expect(listFinder).to.have.length(0);
+ });
+ });
});
});
});
diff --git a/frontend/webpack.config.js b/frontend/webpack.config.js
index ef06d1219b2..f6369c8dad6 100644
--- a/frontend/webpack.config.js
+++ b/frontend/webpack.config.js
@@ -122,7 +122,9 @@ module.exports = {
'angular-context-menu': 'angular-context-menu/dist/angular-context-menu.js',
'mousetrap': 'mousetrap/mousetrap.js',
'hyperagent': 'hyperagent/dist/hyperagent',
- 'openproject-ui_components': 'openproject-ui_components/app/assets/javascripts/angular/ui-components-app'
+ 'openproject-ui_components':
+ 'openproject-ui_components/app/assets/javascripts/angular/ui-components-app',
+ 'ngFileUpload': 'ng-file-upload/ng-file-upload'
}, pluginAliases)
},
diff --git a/lib/api/decorators/single.rb b/lib/api/decorators/single.rb
index 6a88f13a105..261b10be839 100644
--- a/lib/api/decorators/single.rb
+++ b/lib/api/decorators/single.rb
@@ -54,10 +54,10 @@ module API
exec_context: :decorator,
render_nil: false
- def self.self_link(path: nil, title_getter: -> (*) { represented.name })
+ def self.self_link(path: nil, id_attribute: :id, title_getter: -> (*) { represented.name })
link :self do
path = _type.underscore unless path
- link_object = { href: api_v3_paths.send(path, represented.id) }
+ link_object = { href: api_v3_paths.send(path, represented.send(id_attribute)) }
title = instance_eval(&title_getter)
link_object[:title] = title if title
diff --git a/lib/api/v3/activities/activities_api.rb b/lib/api/v3/activities/activities_api.rb
index 9aa07924235..975879c8ce8 100644
--- a/lib/api/v3/activities/activities_api.rb
+++ b/lib/api/v3/activities/activities_api.rb
@@ -38,42 +38,40 @@ module API
end
route_param :id do
before do
- @activity = Journal.find(params[:id])
- @representer = ActivityRepresenter.new(@activity, current_user: current_user)
- end
+ @activity = Journal::AggregatedJournal.with_notes_id(params[:id])
+ raise API::Errors::NotFound unless @activity
- get do
authorize(:view_project, context: @activity.journable.project)
- @representer
end
helpers do
def save_activity(activity)
- if activity.save
- representer = ActivityRepresenter.new(activity)
-
- representer
- else
+ unless activity.save
fail ::API::Errors::ErrorBase.create_and_merge_errors(activity.errors)
end
end
def authorize_edit_own(activity)
- return authorize({ controller: :journals, action: :edit }, context: @activity.journable.project)
- raise API::Errors::Unauthorized.new(current_user) unless activity.editable_by?(current_user)
+ authorize({ controller: :journals, action: :edit },
+ context: activity.journable.project)
end
end
+ get do
+ ActivityRepresenter.new(@activity, current_user: current_user)
+ end
+
params do
requires :comment, type: String
end
patch do
- authorize_edit_own(@activity)
+ editable_activity = Journal.find(@activity.notes_id)
+ authorize_edit_own(editable_activity)
+ editable_activity.notes = params[:comment]
+ save_activity(editable_activity)
- @activity.notes = params[:comment]
-
- save_activity(@activity)
+ ActivityRepresenter.new(@activity.reloaded, current_user: current_user)
end
end
end
diff --git a/lib/api/v3/activities/activities_by_work_package_api.rb b/lib/api/v3/activities/activities_by_work_package_api.rb
new file mode 100644
index 00000000000..374a5c51e6d
--- /dev/null
+++ b/lib/api/v3/activities/activities_by_work_package_api.rb
@@ -0,0 +1,65 @@
+#-- copyright
+# OpenProject is a project management system.
+# Copyright (C) 2012-2015 the OpenProject Foundation (OPF)
+#
+# This program is free software; you can redistribute it and/or
+# modify it under the terms of the GNU General Public License version 3.
+#
+# OpenProject is a fork of ChiliProject, which is a fork of Redmine. The copyright follows:
+# Copyright (C) 2006-2013 Jean-Philippe Lang
+# Copyright (C) 2010-2013 the ChiliProject Team
+#
+# This program is free software; you can redistribute it and/or
+# modify it under the terms of the GNU General Public License
+# as published by the Free Software Foundation; either version 2
+# of the License, or (at your option) any later version.
+#
+# This program is distributed in the hope that it will be useful,
+# but WITHOUT ANY WARRANTY; without even the implied warranty of
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+# GNU General Public License for more details.
+#
+# You should have received a copy of the GNU General Public License
+# along with this program; if not, write to the Free Software
+# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
+#
+# See doc/COPYRIGHT.rdoc for more details.
+#++
+
+require 'api/v3/activities/activity_representer'
+
+module API
+ module V3
+ module Activities
+ class ActivitiesByWorkPackageAPI < ::API::OpenProjectAPI
+ resource :activities do
+ helpers do
+ def save_work_package(work_package)
+ if work_package.save
+ journals = ::Journal::AggregatedJournal.aggregated_journals(
+ journable: work_package)
+ Activities::ActivityRepresenter.new(journals.last, current_user: current_user)
+ else
+ fail ::API::Errors::ErrorBase.create_and_merge_errors(work_package.errors)
+ end
+ end
+ end
+
+ params do
+ requires :comment, type: String
+ end
+ post do
+ authorize({ controller: :journals, action: :new },
+ context: @work_package.project) do
+ raise ::API::Errors::NotFound.new
+ end
+
+ @work_package.journal_notes = params[:comment]
+
+ save_work_package(@work_package)
+ end
+ end
+ end
+ end
+ end
+end
diff --git a/lib/api/v3/activities/activity_representer.rb b/lib/api/v3/activities/activity_representer.rb
index 8438e298754..52617814409 100644
--- a/lib/api/v3/activities/activity_representer.rb
+++ b/lib/api/v3/activities/activity_representer.rb
@@ -36,6 +36,7 @@ module API
include API::V3::Utilities
self_link path: :activity,
+ id_attribute: :notes_id,
title_getter: -> (*) { nil }
link :workPackage do
@@ -47,20 +48,20 @@ module API
link :user do
{
- href: api_v3_paths.user(represented.user.id),
- title: "#{represented.user.name} - #{represented.user.login}"
+ href: api_v3_paths.user(represented.user.id)
}
end
link :update do
{
- href: api_v3_paths.activity(represented.id),
- method: :patch,
- title: "#{represented.id}"
+ href: api_v3_paths.activity(represented.notes_id),
+ method: :patch
} if current_user_allowed_to_edit?
end
- property :id, render_nil: true
+ property :id,
+ getter: -> (*) { notes_id },
+ render_nil: true
property :comment,
exec_context: :decorator,
getter: -> (*) {
diff --git a/lib/api/v3/attachments/attachments_by_work_package_api.rb b/lib/api/v3/attachments/attachments_by_work_package_api.rb
index f500920f500..5c7287942f0 100644
--- a/lib/api/v3/attachments/attachments_by_work_package_api.rb
+++ b/lib/api/v3/attachments/attachments_by_work_package_api.rb
@@ -48,16 +48,6 @@ module API
metadata
end
-
- def make_attachment(metadata, file)
- uploaded_file = OpenProject::Files.build_uploaded_file file[:tempfile],
- file[:type],
- file_name: metadata.file_name
- Attachment.new(file: uploaded_file,
- container: @work_package,
- description: metadata.description,
- author: current_user)
- end
end
get do
@@ -79,9 +69,16 @@ module API
I18n.t('api_v3.errors.multipart_body_error'))
end
- attachment = make_attachment(metadata, file)
- unless attachment.save
- raise ::API::Errors::ErrorBase.create_and_merge_errors(attachment.errors)
+ uploaded_file = OpenProject::Files.build_uploaded_file file[:tempfile],
+ file[:type],
+ file_name: metadata.file_name
+
+ begin
+ service = AddAttachmentService.new(@work_package, author: current_user)
+ attachment = service.add_attachment uploaded_file: uploaded_file,
+ description: metadata.description
+ rescue ActiveRecord::RecordInvalid => error
+ raise ::API::Errors::ErrorBase.create_and_merge_errors(error.record.errors)
end
::API::V3::Attachments::AttachmentRepresenter.new(attachment)
diff --git a/lib/api/v3/repositories/revision_representer.rb b/lib/api/v3/repositories/revision_representer.rb
new file mode 100644
index 00000000000..0bde2806825
--- /dev/null
+++ b/lib/api/v3/repositories/revision_representer.rb
@@ -0,0 +1,77 @@
+#-- encoding: UTF-8
+#-- copyright
+# OpenProject is a project management system.
+# Copyright (C) 2012-2015 the OpenProject Foundation (OPF)
+#
+# This program is free software; you can redistribute it and/or
+# modify it under the terms of the GNU General Public License version 3.
+#
+# OpenProject is a fork of ChiliProject, which is a fork of Redmine. The copyright follows:
+# Copyright (C) 2006-2013 Jean-Philippe Lang
+# Copyright (C) 2010-2013 the ChiliProject Team
+#
+# This program is free software; you can redistribute it and/or
+# modify it under the terms of the GNU General Public License
+# as published by the Free Software Foundation; either version 2
+# of the License, or (at your option) any later version.
+#
+# This program is distributed in the hope that it will be useful,
+# but WITHOUT ANY WARRANTY; without even the implied warranty of
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+# GNU General Public License for more details.
+#
+# You should have received a copy of the GNU General Public License
+# along with this program; if not, write to the Free Software
+# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
+#
+# See doc/COPYRIGHT.rdoc for more details.
+#++
+
+module API
+ module V3
+ module Repositories
+ class RevisionRepresenter < ::API::Decorators::Single
+ include API::V3::Utilities
+
+ self_link path: :revision,
+ title_getter: -> (*) { nil }
+
+ link :project do
+ {
+ href: api_v3_paths.project(represented.project.id),
+ title: represented.project.name
+ }
+ end
+
+ link :author do
+ {
+ href: api_v3_paths.user(represented.user.id),
+ title: represented.user.name
+ } unless represented.user.nil?
+ end
+
+ property :id
+ property :identifier
+ property :author, as: :authorName
+ property :message,
+ exec_context: :decorator,
+ getter: -> (*) {
+ ::API::Decorators::Formattable.new(represented.comments,
+ object: represented,
+ format: 'plain')
+ },
+ render_nil: true
+
+ property :created_at,
+ exec_context: :decorator,
+ getter: -> (*) {
+ datetime_formatter.format_datetime(represented.committed_on)
+ }
+
+ def _type
+ 'Revision'
+ end
+ end
+ end
+ end
+end
diff --git a/lib/api/v3/repositories/revisions_api.rb b/lib/api/v3/repositories/revisions_api.rb
new file mode 100644
index 00000000000..f53c5313855
--- /dev/null
+++ b/lib/api/v3/repositories/revisions_api.rb
@@ -0,0 +1,61 @@
+#-- copyright
+# OpenProject is a project management system.
+# Copyright (C) 2012-2015 the OpenProject Foundation (OPF)
+#
+# This program is free software; you can redistribute it and/or
+# modify it under the terms of the GNU General Public License version 3.
+#
+# OpenProject is a fork of ChiliProject, which is a fork of Redmine. The copyright follows:
+# Copyright (C) 2006-2013 Jean-Philippe Lang
+# Copyright (C) 2010-2013 the ChiliProject Team
+#
+# This program is free software; you can redistribute it and/or
+# modify it under the terms of the GNU General Public License
+# as published by the Free Software Foundation; either version 2
+# of the License, or (at your option) any later version.
+#
+# This program is distributed in the hope that it will be useful,
+# but WITHOUT ANY WARRANTY; without even the implied warranty of
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+# GNU General Public License for more details.
+#
+# You should have received a copy of the GNU General Public License
+# along with this program; if not, write to the Free Software
+# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
+#
+# See doc/COPYRIGHT.rdoc for more details.
+#++
+module API
+ module V3
+ module Repositories
+ class RevisionsAPI < ::API::OpenProjectAPI
+ resources :revisions do
+ params do
+ requires :id, desc: 'Revision id'
+ end
+ route_param :id do
+ helpers do
+ attr_reader :revision
+
+ def revision_representer
+ RevisionRepresenter.new(revision)
+ end
+ end
+
+ before do
+ @revision = Changeset.find(params[:id])
+
+ authorize(:view_changesets, context: revision.project) do
+ raise API::Errors::NotFound.new
+ end
+ end
+
+ get do
+ revision_representer
+ end
+ end
+ end
+ end
+ end
+ end
+end
diff --git a/lib/api/v3/repositories/revisions_by_work_package_api.rb b/lib/api/v3/repositories/revisions_by_work_package_api.rb
new file mode 100644
index 00000000000..492dbf213e1
--- /dev/null
+++ b/lib/api/v3/repositories/revisions_by_work_package_api.rb
@@ -0,0 +1,52 @@
+#-- copyright
+# OpenProject is a project management system.
+# Copyright (C) 2012-2015 the OpenProject Foundation (OPF)
+#
+# This program is free software; you can redistribute it and/or
+# modify it under the terms of the GNU General Public License version 3.
+#
+# OpenProject is a fork of ChiliProject, which is a fork of Redmine. The copyright follows:
+# Copyright (C) 2006-2013 Jean-Philippe Lang
+# Copyright (C) 2010-2013 the ChiliProject Team
+#
+# This program is free software; you can redistribute it and/or
+# modify it under the terms of the GNU General Public License
+# as published by the Free Software Foundation; either version 2
+# of the License, or (at your option) any later version.
+#
+# This program is distributed in the hope that it will be useful,
+# but WITHOUT ANY WARRANTY; without even the implied warranty of
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+# GNU General Public License for more details.
+#
+# You should have received a copy of the GNU General Public License
+# along with this program; if not, write to the Free Software
+# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
+#
+# See doc/COPYRIGHT.rdoc for more details.
+#++
+
+require 'api/v3/repositories/revisions_collection_representer'
+
+module API
+ module V3
+ module Repositories
+ class RevisionsByWorkPackageAPI < ::API::OpenProjectAPI
+ resources :revisions do
+ before do
+ authorize(:view_changesets, context: work_package.project)
+ end
+
+ get do
+ self_path = api_v3_paths.work_package_revisions(work_package.id)
+
+ revisions = work_package.changesets
+ RevisionsCollectionRepresenter.new(revisions,
+ revisions.count,
+ self_path)
+ end
+ end
+ end
+ end
+ end
+end
diff --git a/app/workers/deliver_work_package_updated_job.rb b/lib/api/v3/repositories/revisions_collection_representer.rb
similarity index 77%
rename from app/workers/deliver_work_package_updated_job.rb
rename to lib/api/v3/repositories/revisions_collection_representer.rb
index f6912591e3f..104d0bc8c4c 100644
--- a/app/workers/deliver_work_package_updated_job.rb
+++ b/lib/api/v3/repositories/revisions_collection_representer.rb
@@ -27,19 +27,12 @@
# See doc/COPYRIGHT.rdoc for more details.
#++
-class DeliverWorkPackageUpdatedJob < MailNotificationJob
- def initialize(recipient_id, journal_id, author_id)
- super(recipient_id, author_id)
- @journal_id = journal_id
- end
-
- private
-
- def notification_mail
- @notification_mail ||= UserMailer.work_package_updated(recipient, journal, author)
- end
-
- def journal
- @journal ||= Journal.find(@journal_id)
+module API
+ module V3
+ module Repositories
+ class RevisionsCollectionRepresenter < ::API::Decorators::Collection
+ element_decorator ::API::V3::Repositories::RevisionRepresenter
+ end
+ end
end
end
diff --git a/lib/api/v3/root.rb b/lib/api/v3/root.rb
index 3f389ea47ed..a437a8b3611 100644
--- a/lib/api/v3/root.rb
+++ b/lib/api/v3/root.rb
@@ -42,6 +42,7 @@ module API
mount ::API::V3::Projects::ProjectsAPI
mount ::API::V3::Queries::QueriesAPI
mount ::API::V3::Render::RenderAPI
+ mount ::API::V3::Repositories::RevisionsAPI
mount ::API::V3::Statuses::StatusesAPI
mount ::API::V3::StringObjects::StringObjectsAPI
mount ::API::V3::Types::TypesAPI
diff --git a/lib/api/v3/utilities/path_helper.rb b/lib/api/v3/utilities/path_helper.rb
index af52b2f81f9..49f37f4dbd3 100644
--- a/lib/api/v3/utilities/path_helper.rb
+++ b/lib/api/v3/utilities/path_helper.rb
@@ -34,6 +34,8 @@ module API
include API::Utilities::UrlHelper
class ApiV3Path
+ extend API::Utilities::UrlHelper
+
def self.root
"#{root_path}api/v3"
end
@@ -47,7 +49,7 @@ module API
end
def self.attachment_download(id)
- Rails.application.routes.url_helpers.attachment_path(id)
+ attachment_path(id)
end
def self.attachments_by_work_package(id)
@@ -114,6 +116,10 @@ module API
"#{root}/relations/#{id}"
end
+ def self.revision(id)
+ "#{root}/revisions/#{id}"
+ end
+
def self.render_markup(format: nil, link: nil)
format = format || Setting.text_formatting
format = 'plain' if format == '' # Setting will return '' for plain
@@ -200,6 +206,10 @@ module API
"#{work_package_relations(work_package_id)}/#{id}"
end
+ def self.work_package_revisions(id)
+ "#{work_package(id)}/revisions"
+ end
+
def self.work_package_schema(project_id, type_id)
"#{root}/work_packages/schemas/#{project_id}-#{type_id}"
end
@@ -211,12 +221,6 @@ module API
def self.work_packages_by_project(project_id)
"#{project(project_id)}/work_packages"
end
-
- def self.root_path
- @@root_path ||= Class.new.tap do |c|
- c.extend(::API::V3::Utilities::PathHelper)
- end.root_path
- end
end
def api_v3_paths
diff --git a/lib/api/v3/work_packages/create_form_api.rb b/lib/api/v3/work_packages/create_form_api.rb
index 9b9388a1d8d..c87dd5ec467 100644
--- a/lib/api/v3/work_packages/create_form_api.rb
+++ b/lib/api/v3/work_packages/create_form_api.rb
@@ -27,6 +27,7 @@
#++
require 'api/v3/work_packages/work_packages_shared_helpers'
+require 'api/v3/work_packages/create_contract'
module API
module V3
@@ -36,8 +37,9 @@ module API
helpers ::API::V3::WorkPackages::WorkPackagesSharedHelpers
post do
+ create_contract = ::API::V3::WorkPackages::CreateContract
create_work_package_form(create_service.create,
- contract_class: CreateContract,
+ contract_class: create_contract,
form_class: CreateFormRepresenter)
end
end
diff --git a/lib/api/v3/work_packages/work_package_representer.rb b/lib/api/v3/work_packages/work_package_representer.rb
index afb482d21b4..019f1082128 100644
--- a/lib/api/v3/work_packages/work_package_representer.rb
+++ b/lib/api/v3/work_packages/work_package_representer.rb
@@ -128,6 +128,12 @@ module API
} if current_user_allowed_to(:add_work_package_watchers, context: represented.project)
end
+ link :revisions do
+ {
+ href: api_v3_paths.work_package_revisions(represented.id)
+ } if current_user_allowed_to(:view_changesets, context: represented.project)
+ end
+
link :watch do
{
href: api_v3_paths.work_package_watchers(represented.id),
@@ -307,7 +313,7 @@ module API
end
def activities
- represented.journals.map do |activity|
+ ::Journal::AggregatedJournal.aggregated_journals(journable: represented).map do |activity|
::API::V3::Activities::ActivityRepresenter.new(activity, current_user: current_user)
end
end
diff --git a/lib/api/v3/work_packages/work_packages_api.rb b/lib/api/v3/work_packages/work_packages_api.rb
index 1c3a13a2f1a..ddc11b16154 100644
--- a/lib/api/v3/work_packages/work_packages_api.rb
+++ b/lib/api/v3/work_packages/work_packages_api.rb
@@ -26,7 +26,6 @@
# See doc/COPYRIGHT.rdoc for more details.
#++
-require 'api/v3/activities/activity_representer'
require 'api/v3/work_packages/work_package_representer'
module API
@@ -79,36 +78,11 @@ module API
end
end
- resource :activities do
- helpers do
- def save_work_package(work_package)
- if work_package.save
- Activities::ActivityRepresenter.new(work_package.journals.last,
- current_user: current_user)
- else
- fail ::API::Errors::ErrorBase.create_and_merge_errors(work_package.errors)
- end
- end
- end
-
- params do
- requires :comment, type: String
- end
- post do
- authorize({ controller: :journals, action: :new },
- context: @work_package.project) do
- raise ::API::Errors::NotFound.new
- end
-
- @work_package.journal_notes = params[:comment]
-
- save_work_package(@work_package)
- end
- end
-
mount ::API::V3::WorkPackages::WatchersAPI
mount ::API::V3::Relations::RelationsAPI
+ mount ::API::V3::Activities::ActivitiesByWorkPackageAPI
mount ::API::V3::Attachments::AttachmentsByWorkPackageAPI
+ mount ::API::V3::Repositories::RevisionsByWorkPackageAPI
mount ::API::V3::WorkPackages::UpdateFormAPI
end
diff --git a/lib/api/v3/work_packages/work_packages_by_project_api.rb b/lib/api/v3/work_packages/work_packages_by_project_api.rb
index 320e0947a00..b9d4e809aff 100644
--- a/lib/api/v3/work_packages/work_packages_by_project_api.rb
+++ b/lib/api/v3/work_packages/work_packages_by_project_api.rb
@@ -28,6 +28,7 @@
require 'api/v3/work_packages/work_package_representer'
require 'api/v3/work_packages/work_packages_shared_helpers'
+require 'api/v3/work_packages/create_contract'
module API
module V3
@@ -50,10 +51,9 @@ module API
write_work_package_attributes work_package
- contract = WorkPackages::CreateContract.new(work_package, current_user)
+ contract = ::API::V3::WorkPackages::CreateContract.new(work_package, current_user)
if contract.validate && create_service.save(work_package)
work_package.reload
-
WorkPackages::WorkPackageRepresenter.create(work_package, current_user: current_user)
else
fail ::API::Errors::ErrorBase.create_and_merge_errors(contract.errors)
diff --git a/lib/open_project/configuration.rb b/lib/open_project/configuration.rb
index ab817438d16..b61d6450402 100644
--- a/lib/open_project/configuration.rb
+++ b/lib/open_project/configuration.rb
@@ -43,7 +43,6 @@ module OpenProject
'autologin_cookie_path' => '/',
'autologin_cookie_secure' => false,
'database_cipher_key' => nil,
- 'scm_filesystem_path_whitelist' => [],
'scm_git_command' => nil,
'scm_subversion_command' => nil,
'disable_browser_cache' => true,
diff --git a/lib/open_project/journal_formatter/diff.rb b/lib/open_project/journal_formatter/diff.rb
index 21685c0325d..92867823cf5 100644
--- a/lib/open_project/journal_formatter/diff.rb
+++ b/lib/open_project/journal_formatter/diff.rb
@@ -69,6 +69,7 @@ class OpenProject::JournalFormatter::Diff < JournalFormatter::Base
end
def link(key, options)
+
url_attr = default_attributes(options).merge(controller: '/journals',
action: 'diff',
id: @journal.id,
diff --git a/lib/open_project/scm/adapters.rb b/lib/open_project/scm/adapters.rb
new file mode 100644
index 00000000000..041fd57a8c5
--- /dev/null
+++ b/lib/open_project/scm/adapters.rb
@@ -0,0 +1,131 @@
+#-- encoding: UTF-8
+#-- copyright
+# OpenProject is a project management system.
+# Copyright (C) 2012-2015 the OpenProject Foundation (OPF)
+#
+# This program is free software; you can redistribute it and/or
+# modify it under the terms of the GNU General Public License version 3.
+#
+# OpenProject is a fork of ChiliProject, which is a fork of Redmine. The copyright follows:
+# Copyright (C) 2006-2013 Jean-Philippe Lang
+# Copyright (C) 2010-2013 the ChiliProject Team
+#
+# This program is free software; you can redistribute it and/or
+# modify it under the terms of the GNU General Public License
+# as published by the Free Software Foundation; either version 2
+# of the License, or (at your option) any later version.
+#
+# This program is distributed in the hope that it will be useful,
+# but WITHOUT ANY WARRANTY; without even the implied warranty of
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+# GNU General Public License for more details.
+#
+# You should have received a copy of the GNU General Public License
+# along with this program; if not, write to the Free Software
+# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
+#
+# See doc/COPYRIGHT.rdoc for more details.
+#++
+module OpenProject
+ module Scm
+ module Adapters
+ class Entries < Array
+ def sort_by_name
+ sort do |x, y|
+ if x.kind == y.kind
+ x.name.to_s <=> y.name.to_s
+ else
+ x.kind <=> y.kind
+ end
+ end
+ end
+ end
+
+ class Info
+ attr_accessor :root_url, :lastrev
+ def initialize(attributes = {})
+ self.root_url = attributes[:root_url]
+ self.lastrev = attributes[:lastrev]
+ end
+ end
+
+ class Entry
+ attr_accessor :name, :path, :kind, :size, :lastrev
+ def initialize(attributes = {})
+ [:name, :path, :kind, :size].each do |attr|
+ send("#{attr}=", attributes[attr])
+ end
+
+ self.size = size.to_i if size.present?
+ self.lastrev = attributes[:lastrev]
+ end
+
+ def file?
+ 'file' == kind
+ end
+
+ def dir?
+ 'dir' == kind
+ end
+ end
+
+ class Revisions < Array
+ def latest
+ sort { |x, y|
+ if x.time.nil? or y.time.nil?
+ 0
+ else
+ x.time <=> y.time
+ end
+ }.last
+ end
+ end
+
+ class Revision
+ attr_accessor :scmid, :name, :author, :time, :message, :paths, :revision, :branch
+ attr_writer :identifier
+
+ def initialize(attributes = {})
+ [:identifier, :scmid, :author, :time, :paths, :revision, :branch].each do |attr|
+ send("#{attr}=", attributes[attr])
+ end
+
+ self.name = attributes[:name].presence || identifier
+ self.message = attributes[:message].presence || ''
+ end
+
+ # Returns the identifier of this revision; see also Changeset model
+ def identifier
+ (@identifier || revision).to_s
+ end
+
+ # Returns the readable identifier.
+ def format_identifier
+ identifier
+ end
+ end
+
+ class Annotate
+ attr_reader :lines, :revisions
+
+ def initialize
+ @lines = []
+ @revisions = []
+ end
+
+ def add_line(line, revision)
+ @lines << line
+ @revisions << revision
+ end
+
+ def content
+ lines.join("\n")
+ end
+
+ def empty?
+ lines.empty?
+ end
+ end
+ end
+ end
+end
diff --git a/lib/open_project/scm/adapters/base.rb b/lib/open_project/scm/adapters/base.rb
new file mode 100644
index 00000000000..5b0777bcfd3
--- /dev/null
+++ b/lib/open_project/scm/adapters/base.rb
@@ -0,0 +1,165 @@
+#-- encoding: UTF-8
+#-- copyright
+# OpenProject is a project management system.
+# Copyright (C) 2012-2015 the OpenProject Foundation (OPF)
+#
+# This program is free software; you can redistribute it and/or
+# modify it under the terms of the GNU General Public License version 3.
+#
+# OpenProject is a fork of ChiliProject, which is a fork of Redmine. The copyright follows:
+# Copyright (C) 2006-2013 Jean-Philippe Lang
+# Copyright (C) 2010-2013 the ChiliProject Team
+#
+# This program is free software; you can redistribute it and/or
+# modify it under the terms of the GNU General Public License
+# as published by the Free Software Foundation; either version 2
+# of the License, or (at your option) any later version.
+#
+# This program is distributed in the hope that it will be useful,
+# but WITHOUT ANY WARRANTY; without even the implied warranty of
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+# GNU General Public License for more details.
+#
+# You should have received a copy of the GNU General Public License
+# along with this program; if not, write to the Free Software
+# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
+#
+# See doc/COPYRIGHT.rdoc for more details.
+#++
+
+require 'open_project/scm/adapters'
+
+module OpenProject
+ module Scm
+ module Adapters
+ class Base
+ attr_accessor :url, :root_url
+
+ def self.vendor
+ name.demodulize
+ end
+
+ def initialize(url, root_url = nil)
+ self.url = url
+ self.root_url = root_url
+ end
+
+ def local?
+ false
+ end
+
+ ##
+ # Overriden by descendants when
+ # they are able to retrieve current
+ # storage usage.
+ def storage_available?
+ false
+ end
+
+ def available?
+ check_availability!
+ true
+ rescue Exceptions::ScmError => e
+ logger.error("Failed to retrieve availability of repository: #{e.message}")
+ false
+ end
+
+ def logger
+ Rails.logger
+ end
+
+ def vendor
+ self.class.vendor
+ end
+
+ def info
+ nil
+ end
+
+ # Returns the entry identified by path and revision identifier
+ # or nil if entry doesn't exist in the repository
+ def entry(path = nil, identifier = nil)
+ parts = path.to_s.split(%r{[\/\\]}).select { |n| !n.blank? }
+ search_entries(parts, identifier)
+ end
+
+ def search_entries(parts, identifier)
+ search_path = parts[0..-2].join('/')
+ search_name = parts[-1]
+
+ if search_path.blank? && search_name.blank?
+ # Root entry
+ Entry.new(path: '', kind: 'dir')
+ else
+ # Search for the entry in the parent directory
+ es = entries(search_path, identifier)
+ es ? es.detect { |e| e.name == search_name } : nil
+ end
+ end
+
+ # Returns an Entries collection
+ # or nil if the given path doesn't exist in the repository
+ def entries(_path = nil, _identifier = nil)
+ nil
+ end
+
+ def branches
+ nil
+ end
+
+ def tags
+ nil
+ end
+
+ def default_branch
+ nil
+ end
+
+ def properties(_path, _identifier = nil)
+ nil
+ end
+
+ def revisions(_path = nil, _identifier_from = nil, _identifier_to = nil, _options = {})
+ nil
+ end
+
+ def diff(_path, _identifier_from, _identifier_to = nil)
+ nil
+ end
+
+ def cat(_path, _identifier = nil)
+ nil
+ end
+
+ def with_leading_slash(path)
+ path ||= ''
+ (path[0, 1] != '/') ? "/#{path}" : path
+ end
+
+ def with_trailling_slash(path)
+ path ||= ''
+ (path[-1, 1] == '/') ? path : "#{path}/"
+ end
+
+ def without_leading_slash(path)
+ path ||= ''
+ path.gsub(%r{\A/+}, '')
+ end
+
+ def without_trailling_slash(path)
+ path ||= ''
+ (path[-1, 1] == '/') ? path[0..-2] : path
+ end
+ end
+
+ class Info
+ attr_accessor :root_url, :lastrev
+
+ def initialize(attributes = {})
+ self.root_url = attributes[:root_url] if attributes[:root_url]
+ self.lastrev = attributes[:lastrev]
+ end
+ end
+ end
+ end
+end
diff --git a/lib/open_project/scm/adapters/git.rb b/lib/open_project/scm/adapters/git.rb
new file mode 100644
index 00000000000..a5d41677bf8
--- /dev/null
+++ b/lib/open_project/scm/adapters/git.rb
@@ -0,0 +1,395 @@
+#-- encoding: UTF-8
+#-- copyright
+# OpenProject is a project management system.
+# Copyright (C) 2012-2015 the OpenProject Foundation (OPF)
+#
+# This program is free software; you can redistribute it and/or
+# modify it under the terms of the GNU General Public License version 3.
+#
+# OpenProject is a fork of ChiliProject, which is a fork of Redmine. The copyright follows:
+# Copyright (C) 2006-2013 Jean-Philippe Lang
+# Copyright (C) 2010-2013 the ChiliProject Team
+#
+# This program is free software; you can redistribute it and/or
+# modify it under the terms of the GNU General Public License
+# as published by the Free Software Foundation; either version 2
+# of the License, or (at your option) any later version.
+#
+# This program is distributed in the hope that it will be useful,
+# but WITHOUT ANY WARRANTY; without even the implied warranty of
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+# GNU General Public License for more details.
+#
+# You should have received a copy of the GNU General Public License
+# along with this program; if not, write to the Free Software
+# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
+#
+# See doc/COPYRIGHT.rdoc for more details.
+#++
+
+require_dependency 'open_project/scm/adapters'
+
+module OpenProject
+ module Scm
+ module Adapters
+ class Git < Base
+ include LocalClient
+
+ SCM_GIT_REPORT_LAST_COMMIT = true
+
+ def initialize(url, root_url = nil, _login = nil, _password = nil, path_encoding = nil)
+ super(url, root_url)
+ @flag_report_last_commit = SCM_GIT_REPORT_LAST_COMMIT
+ @path_encoding = path_encoding.presence || 'UTF-8'
+ end
+
+ def client_command
+ @client_command ||= self.class.config[:client_command] || 'git'
+ end
+
+ def client_version
+ @client_version ||= (git_binary_version || [])
+ end
+
+ def scm_version_from_command_line
+ capture_out(%w[--version --no-color])
+ end
+
+ def git_binary_version
+ scm_version = scm_version_from_command_line.dup
+ if scm_version.respond_to?(:force_encoding)
+ scm_version.force_encoding('ASCII-8BIT')
+ end
+ m = scm_version.match(%r{\A(.*?)((\d+\.)+\d+)})
+ unless m.nil?
+ m[2].scan(%r{\d+}).map(&:to_i)
+ end
+ end
+
+ ##
+ # Create a bare repository for the current path
+ def initialize_bare_git
+ capture_git(%w[init --bare])
+ end
+
+ ##
+ # Checks the status of this repository and throws unless it can be accessed
+ # correctly by the adapter.
+ #
+ # @raise [ScmUnavailable] raised when repository is unavailable.
+ def check_availability!
+ out, = Open3.capture2e(client_command, *build_git_cmd(%w[log -- HEAD]))
+ raise Exceptions::ScmEmpty if out.include?("fatal: bad default revision 'HEAD'")
+
+ # If it not empty, it should have at least one readable branch.
+ raise Exceptions::ScmUnavailable unless branches.size > 0
+ rescue Exceptions::CommandFailed => e
+ logger.error("Availability check failed due to failed Git command: #{e.message}")
+ raise Exceptions::ScmUnavailable
+ end
+
+ def info
+ Info.new(root_url: url, lastrev: lastrev('', nil))
+ end
+
+ def branches
+ return @branches if @branches
+ @branches = []
+ cmd_args = %w|branch --no-color|
+ popen3(cmd_args) do |io|
+ io.each_line do |line|
+ @branches << line.match('\s*\*?\s*(.*)$')[1]
+ end
+ end
+ @branches.sort!
+ end
+
+ def tags
+ return @tags if @tags
+ cmd_args = %w|tag|
+ @tags = capture_git(cmd_args).lines.sort!.map(&:strip)
+ end
+
+ def default_branch
+ bras = branches
+ return nil if bras.nil?
+ bras.include?('master') ? 'master' : bras.first
+ end
+
+ def entries(path, identifier = nil)
+ entries = Entries.new
+ path = scm_encode(@path_encoding, 'UTF-8', path)
+ args = %w|ls-tree -l|
+ args << "HEAD:#{path}" if identifier.nil?
+ args << "#{identifier}:#{path}" if identifier
+
+ parse_by_line(args, binmode: true) do |line|
+ e = parse_entry(line, path, identifier)
+ entries << e unless entries.detect { |entry| entry.name == e.name }
+ end
+
+ entries.sort_by_name
+ end
+
+ def parse_entry(line, path, identifier)
+ if line.chomp =~ /^\d+\s+(\w+)\s+([0-9a-f]{40})\s+([0-9-]+)\t(.+)$/
+ type = $1
+ size = $3
+ name = $4.force_encoding(@path_encoding)
+ path = encode_full_path(name, path || '')
+
+ Entry.new(
+ name: scm_encode('UTF-8', @path_encoding, name),
+ path: path,
+ kind: (type == 'tree') ? 'dir' : 'file',
+ size: (type == 'tree') ? nil : size,
+ lastrev: @flag_report_last_commit ? lastrev(path, identifier) : Revision.new
+ )
+ end
+ end
+
+ def encode_full_path(name, path)
+ full_path = path.empty? ? name : "#{path}/#{name}"
+ scm_encode('UTF-8', @path_encoding, full_path)
+ end
+
+ def lastrev(path, rev)
+ return nil if path.nil?
+ args = %w|log --no-color --encoding=UTF-8 --date=iso --pretty=fuller --no-merges -n 1|
+ args << rev if rev
+ args << '--' << path unless path.empty?
+ lines = capture_git(args).lines
+ begin
+ build_lastrev(lines)
+ rescue NoMethodError
+ logger.error("The revision '#{path}' has a wrong format")
+ return nil
+ end
+ end
+
+ def build_lastrev(lines)
+ id = lines[0].split[1]
+ author = lines[1].match('Author:\s+(.*)$')[1]
+ time = Time.parse(lines[4].match('CommitDate:\s+(.*)$')[1])
+
+ Revision.new(
+ identifier: id,
+ scmid: id,
+ author: author,
+ time: time,
+ message: nil,
+ paths: nil
+ )
+ end
+
+ def revisions(path, identifier_from, identifier_to, options = {})
+ revisions = Revisions.new
+ args = build_revision_args(path, identifier_from, identifier_to, options)
+
+ files = []
+ changeset = {}
+ parsing_descr = 0 # 0: not parsing desc or files, 1: parsing desc, 2: parsing files
+ parse_by_line(args, binmode: true) do |line|
+ if line =~ /^commit ([0-9a-f]{40})$/
+ key = 'commit'
+ value = $1
+ if parsing_descr == 1 || parsing_descr == 2
+ parsing_descr = 0
+ revision = Revision.new(
+ identifier: changeset[:commit],
+ scmid: changeset[:commit],
+ author: changeset[:author],
+ time: Time.parse(changeset[:date]),
+ message: changeset[:description],
+ paths: files
+ )
+ if block_given?
+ yield revision
+ else
+ revisions << revision
+ end
+ changeset = {}
+ files = []
+ end
+ changeset[:commit] = $1
+ elsif (parsing_descr == 0) && line =~ /^(\w+):\s*(.*)$/
+ key = $1
+ value = $2
+ if key == 'Author'
+ changeset[:author] = value
+ elsif key == 'CommitDate'
+ changeset[:date] = value
+ end
+ elsif (parsing_descr == 0) && line.chomp.to_s == ''
+ parsing_descr = 1
+ changeset[:description] = ''
+ elsif (parsing_descr == 1 || parsing_descr == 2) &&
+ (line =~ /^:\d+\s+\d+\s+[0-9a-f.]+\s+[0-9a-f.]+\s+(\w)\t(.+)$/)
+
+ parsing_descr = 2
+ fileaction = $1
+ filepath = $2
+ p = scm_encode('UTF-8', @path_encoding, filepath)
+ files << { action: fileaction, path: p }
+ elsif (parsing_descr == 1 || parsing_descr == 2) &&
+ (line =~ /^:\d+\s+\d+\s+[0-9a-f.]+\s+[0-9a-f.]+\s+(\w)\d+\s+(\S+)\t(.+)$/)
+
+ parsing_descr = 2
+ fileaction = $1
+ filepath = $3
+ p = scm_encode('UTF-8', @path_encoding, filepath)
+ files << { action: fileaction, path: p }
+ elsif (parsing_descr == 1) && line.chomp.to_s == ''
+ parsing_descr = 2
+ elsif parsing_descr == 1
+ changeset[:description] << line[4..-1]
+ end
+ end
+
+ if changeset[:commit]
+ revision = Revision.new(
+ identifier: changeset[:commit],
+ scmid: changeset[:commit],
+ author: changeset[:author],
+ time: Time.parse(changeset[:date]),
+ message: changeset[:description],
+ paths: files
+ )
+
+ if block_given?
+ yield revision
+ else
+ revisions << revision
+ end
+ end
+
+ revisions
+ end
+
+ def build_revision_args(path, identifier_from, identifier_to, options)
+ args = %w|log --no-color --encoding=UTF-8 --raw --date=iso --pretty=fuller|
+ args << '--reverse' if options[:reverse]
+ args << '--all' if options[:all]
+ args << '-n' << "#{options[:limit].to_i}" if options[:limit]
+ from_to = ''
+ from_to << "#{identifier_from}.." if identifier_from
+ from_to << "#{identifier_to}" if identifier_to
+ args << from_to if from_to.present?
+ args << "--since=#{options[:since].strftime('%Y-%m-%d %H:%M:%S')}" if options[:since]
+ args << '--' << scm_encode(@path_encoding, 'UTF-8', path) if path && !path.empty?
+
+ args
+ end
+
+ def diff(path, identifier_from, identifier_to = nil)
+ args = []
+ if identifier_to
+ args << 'diff' << '--no-color' << identifier_to << identifier_from
+ else
+ args << 'show' << '--no-color' << identifier_from
+ end
+ args << '--' << scm_encode(@path_encoding, 'UTF-8', path) unless path.empty?
+ capture_git(args).lines.map(&:chomp)
+ rescue Exceptions::CommandFailed
+ nil
+ end
+
+ def annotate(path, identifier = nil)
+ identifier = 'HEAD' if identifier.blank?
+ args = %w|blame --encoding=UTF-8|
+ args << '-p' << identifier << '--' << scm_encode(@path_encoding, 'UTF-8', path)
+ blame = Annotate.new
+ content = capture_git(args, binmode: true)
+
+ # Deny to parse large binary files
+ # Quick test for null bytes, this may not match all files,
+ # but should be a reasonable workaround
+ return nil if content.dup.force_encoding('BINARY').count("\x00") > 0
+
+ identifier = ''
+ # git shows commit author on the first occurrence only
+ authors_by_commit = {}
+ content.scrub.split("\n").each do |line|
+ if line =~ /^([0-9a-f]{39,40})\s.*/
+ identifier = $1
+ elsif line =~ /^author (.+)/
+ authors_by_commit[identifier] = $1.strip
+ elsif line =~ /^\t(.*)/
+ blame.add_line(
+ $1,
+ Revision.new(
+ identifier: identifier,
+ author: authors_by_commit[identifier]))
+ identifier = ''
+ end
+ end
+ blame
+ end
+
+ def cat(path, identifier = nil)
+ if identifier.nil?
+ identifier = 'HEAD'
+ end
+ args = %w|show --no-color|
+ args << "#{identifier}:#{scm_encode(@path_encoding, 'UTF-8', path)}"
+ capture_git(args, binmode: true)
+ end
+
+ class Revision < OpenProject::Scm::Adapters::Revision
+ # Returns the readable identifier
+ def format_identifier
+ identifier[0, 8]
+ end
+ end
+
+ private
+
+ ##
+ # Builds the full git arguments from the parameters
+ # and return the executed stdout as a string
+ def capture_git(args, opt = {})
+ cmd = build_git_cmd(args)
+ capture_out(cmd, opt)
+ end
+
+ ##
+ # Builds the full git arguments from the parameters
+ # and calls the given block with in, out, err, thread
+ # from +Open3#popen3+.
+ def popen3(args, opt = {}, &block)
+ cmd = build_git_cmd(args)
+ super(cmd, opt) do |_stdin, stdout, _stderr, wait_thr|
+ block.call(stdout)
+
+ process = wait_thr.value
+ if process.exitstatus != 0
+ raise Exceptions::CommandFailed.new(
+ 'git',
+ "git exited with non-zero status: #{process.exitstatus}"
+ )
+ end
+ end
+ end
+
+ ##
+ # Runs the given arguments through git
+ # and processes the result line by line.
+ #
+ def parse_by_line(cmd, opts = {}, &block)
+ popen3(cmd) do |io|
+ io.binmode if opts[:binmode]
+ io.each_line &block
+ end
+ end
+
+ def build_git_cmd(args)
+ if client_version_above?([1, 7, 2])
+ args.unshift('-c', 'core.quotepath=false')
+ end
+
+ args.unshift('--git-dir', (root_url.presence || url))
+ end
+ end
+ end
+ end
+end
diff --git a/lib/open_project/scm/adapters/local_client.rb b/lib/open_project/scm/adapters/local_client.rb
new file mode 100644
index 00000000000..5364405e5ba
--- /dev/null
+++ b/lib/open_project/scm/adapters/local_client.rb
@@ -0,0 +1,239 @@
+#-- encoding: UTF-8
+#-- copyright
+# OpenProject is a project management system.
+# Copyright (C) 2012-2015 the OpenProject Foundation (OPF)
+#
+# This program is free software; you can redistribute it and/or
+# modify it under the terms of the GNU General Public License version 3.
+#
+# OpenProject is a fork of ChiliProject, which is a fork of Redmine. The copyright follows:
+# Copyright (C) 2006-2013 Jean-Philippe Lang
+# Copyright (C) 2010-2013 the ChiliProject Team
+#
+# This program is free software; you can redistribute it and/or
+# modify it under the terms of the GNU General Public License
+# as published by the Free Software Foundation; either version 2
+# of the License, or (at your option) any later version.
+#
+# This program is distributed in the hope that it will be useful,
+# but WITHOUT ANY WARRANTY; without even the implied warranty of
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+# GNU General Public License for more details.
+#
+# You should have received a copy of the GNU General Public License
+# along with this program; if not, write to the Free Software
+# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
+#
+# See doc/COPYRIGHT.rdoc for more details.
+#++
+
+require 'open3'
+module OpenProject
+ module Scm
+ module Adapters
+ module LocalClient
+ def self.included(base)
+ base.extend(ClassMethods)
+ end
+
+ module ClassMethods
+ ##
+ # Reads the configuration for this strategy from OpenProject's `configuration.yml`.
+ def config
+ ['scm', vendor].inject(OpenProject::Configuration) do |acc, key|
+ HashWithIndifferentAccess.new acc[key]
+ end
+ end
+ end
+
+ ##
+ # Determines local capabilities for SCM creation.
+ # Overridden by including classes when SCM may be remote.
+ def local?
+ true
+ end
+
+ ##
+ # Determines whether this repository is eligible
+ # to count storage.
+ def storage_available?
+ local? && File.directory?(local_repository_path)
+ end
+
+ ##
+ # Counts the repository storage requirement immediately
+ # or raises an exception if this is impossible for the current repository.
+ def count_repository!
+ if storage_available?
+ count_required_storage
+ else
+ raise Exceptions::ScmError.new I18n.t('repositories.storage.not_available')
+ end
+ end
+
+ ##
+ # Retrieve the local FS path
+ # of this repository.
+ #
+ # Overriden by some vendors, as not
+ # all vendors have a path root_url.
+ # (e.g., subversion uses file:// URLs)
+ def local_repository_path
+ root_url
+ end
+
+ ##
+ # Reads the configuration for this strategy from OpenProject's `configuration.yml`.
+ def config
+ scm_config = OpenProject::Configuration
+ ['scm', vendor].inject(scm_config) do |acc, key|
+ HashWithIndifferentAccess.new acc[key]
+ end
+ end
+
+ ##
+ # client executable command
+ def client_command
+ ''
+ end
+
+ def client_available
+ !client_version.empty?
+ end
+
+ ##
+ # Returns the version of the scm client
+ # Eg: [1, 5, 0] or [] if unknown
+ def client_version
+ []
+ end
+
+ ##
+ # Returns the version string of the scm client
+ # Eg: '1.5.0' or 'Unknown version' if unknown
+ def client_version_string
+ v = client_version || 'Unknown version'
+ v.is_a?(Array) ? v.join('.') : v.to_s
+ end
+
+ ##
+ # Returns true if the current client version is above
+ # or equals the given one
+ # If option is :unknown is set to true, it will return
+ # true if the client version is unknown
+ def client_version_above?(v, options = {})
+ ((client_version <=> v) >= 0) || (client_version.empty? && options[:unknown])
+ end
+
+ def shell_quote(str)
+ Shellwords.escape(str)
+ end
+
+ def supports_cat?
+ true
+ end
+
+ def supports_annotate?
+ respond_to?('annotate')
+ end
+
+ def target(path = '')
+ base = path.match(/\A\//) ? root_url : url
+ shell_quote("#{base}/#{path}".gsub(/[?<>\*]/, ''))
+ end
+
+ ##
+ # Returns true if any line of the IO object
+ # has a line that +include?+ the given part.
+ #
+ # @param [IO] io An IO object from Open3.
+ # @param [String] part The string parameter to +contains?+
+ # @return [Boolean or nil] True iff any line of io includes the part
+ def io_include?(io, part)
+ io.each_line do |l|
+ return true if l.include?(part)
+ end
+ end
+
+ # Executes the given arguments for +client_command+ on the shell
+ # and returns the resulting stdout.
+ #
+ # May optionally specify an opts hash with flags for popen3 and Process.spawn
+ # (cf., :binmode, :stdin_data in +Open3.capture3+)
+ #
+ # If the operation throws an exception or the operation yields a non-zero exit code
+ # we rethrow a +CommandFailed+ with a meaningful error message
+ def capture_out(args, opts = {})
+ output, err, code = Open3.capture3(client_command, *args, binmode: opts[:binmode])
+ if code != 0
+ error_msg = "SCM command failed: Non-zero exit code (#{code}) for `#{client_command}`"
+ logger.error(error_msg)
+ logger.debug("Error output is #{err}")
+ raise Exceptions::CommandFailed.new(client_command, error_msg, err)
+ end
+
+ output
+ end
+
+ # Executes the given arguments for +client_command+ on the shell
+ # and returns stdout, stderr, and the exit code.
+ #
+ # If the operation throws an exception or the operation we rethrow a
+ # +CommandFailed+ with a meaningful error message.
+ def popen3(args, opts = {}, &block)
+ logger.debug "Shelling out: `#{stripped_command(args)}`"
+ Open3.popen3(client_command, *args, opts, &block)
+ rescue Exceptions::ScmError => e
+ raise e
+ rescue => e
+ error_msg = "SCM command for `#{client_command}` failed: #{strip_credential(e.message)}"
+ logger.error(error_msg)
+ raise Exceptions::CommandFailed.new(client_command, error_msg)
+ end
+
+ ##
+ # Returns the full client command and args with stripped credentials
+ def stripped_command(args)
+ "#{client_command} #{strip_credential(args.join(' '))}"
+ end
+
+ ##
+ # Replaces argument values for --username/--password in a given command
+ # with a placeholder
+ def strip_credential(cmd)
+ q = Redmine::Platform.mswin? ? '"' : "'"
+ cmd.to_s.gsub(/(\-\-(password|username))\s+(#{q}[^#{q}]+#{q}|[^#{q}]\S+)/, '\\1 xxxx')
+ end
+
+ def scm_encode(to, from, str)
+ return nil if str.nil?
+ return str if to == from
+ begin
+ str.to_s.encode(to, from)
+ rescue Encoding::InvalidByteSequenceError, Encoding::UndefinedConversionError => err
+ logger.error("failed to convert from #{from} to #{to}. #{err}")
+ nil
+ end
+ end
+
+ private
+
+ ##
+ # Counts the repositories by files in ruby.
+ # For sake of compatibility, iterates all files
+ # in the repository to determine storage size.
+ #
+ # This is compatible, but quite inefficient, so should
+ # be run asynchronously.
+ def count_required_storage
+ bytes = 0
+ Find.find(local_repository_path) do |f|
+ bytes += File.size(f) if File.file?(f)
+ end
+
+ bytes
+ end
+ end
+ end
+ end
+end
diff --git a/lib/open_project/scm/adapters/subversion.rb b/lib/open_project/scm/adapters/subversion.rb
new file mode 100644
index 00000000000..8255e0e9e72
--- /dev/null
+++ b/lib/open_project/scm/adapters/subversion.rb
@@ -0,0 +1,333 @@
+#-- encoding: UTF-8
+#-- copyright
+# OpenProject is a project management system.
+# Copyright (C) 2012-2015 the OpenProject Foundation (OPF)
+#
+# This program is free software; you can redistribute it and/or
+# modify it under the terms of the GNU General Public License version 3.
+#
+# OpenProject is a fork of ChiliProject, which is a fork of Redmine. The copyright follows:
+# Copyright (C) 2006-2013 Jean-Philippe Lang
+# Copyright (C) 2010-2013 the ChiliProject Team
+#
+# This program is free software; you can redistribute it and/or
+# modify it under the terms of the GNU General Public License
+# as published by the Free Software Foundation; either version 2
+# of the License, or (at your option) any later version.
+#
+# This program is distributed in the hope that it will be useful,
+# but WITHOUT ANY WARRANTY; without even the implied warranty of
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+# GNU General Public License for more details.
+#
+# You should have received a copy of the GNU General Public License
+# along with this program; if not, write to the Free Software
+# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
+#
+# See doc/COPYRIGHT.rdoc for more details.
+#++
+
+require 'uri'
+
+module OpenProject
+ module Scm
+ module Adapters
+ class Subversion < Base
+ include LocalClient
+
+ def client_command
+ @client_command ||= self.class.config[:client_command] || 'svn'
+ end
+
+ def svnadmin_command
+ @svnadmin_command ||= (self.class.config[:svnadmin_command] || 'svnadmin')
+ end
+
+ def client_version
+ @client_version ||= (svn_binary_version || [])
+ end
+
+ def svn_binary_version
+ scm_version = scm_version_from_command_line.dup
+ m = scm_version.match(%r{\A(.*?)((\d+\.)+\d+)})
+ if m
+ m[2].scan(%r{\d+}).map(&:to_i)
+ end
+ end
+
+ def scm_version_from_command_line
+ capture_out('--version')
+ end
+
+ ##
+ # Subversion may be local or remote,
+ # for now determine it by the URL type.
+ def local?
+ url.start_with?('file://')
+ end
+
+ ##
+ # Returns the local repository path
+ # (if applicable).
+ def local_repository_path
+ root_url.sub('file://', '')
+ end
+
+ def initialize(url, root_url = nil, login = nil, password = nil, _path_encoding = nil)
+ super(url, root_url)
+
+ @login = login
+ @password = password
+ end
+
+ ##
+ # Checks the status of this repository and throws unless it can be accessed
+ # correctly by the adapter.
+ #
+ # @raise [ScmUnavailable] raised when repository is unavailable.
+ def check_availability!
+ # Check whether we can access svn repository uuid
+ popen3(['info', '--xml', target]) do |stdout, stderr|
+ doc = Nokogiri::XML(stdout.read)
+
+ raise Exceptions::ScmEmpty if doc.at_xpath('/info/entry/commit[@revision="0"]')
+
+ return if doc.at_xpath('/info/entry/repository/uuid')
+
+ raise Exceptions::ScmUnauthorized.new if io_include?(stderr,
+ 'E215004: Authentication failed')
+ end
+
+ raise Exceptions::ScmUnavailable
+ end
+
+ ##
+ # Creates an empty repository using svnadmin
+ #
+ def create_empty_svn
+ _, err, code = Open3.capture3(svnadmin_command, 'create', root_url)
+ if code != 0
+ msg = "Failed to create empty subversion repository with `#{svnadmin_command} create`"
+ logger.error(msg)
+ logger.debug("Error output is #{err}")
+ raise Exceptions::CommandFailed.new(client_command, msg)
+ end
+ end
+
+ # Get info about the svn repository
+ def info
+ cmd = build_svn_cmd(['info', '--xml', target])
+ xml_capture(cmd, force_encoding: true) do |doc|
+ Info.new(
+ root_url: doc.xpath('/info/entry/repository/root').text,
+ lastrev: extract_revision(doc.at_xpath('/info/entry/commit'))
+ )
+ end
+ end
+
+ def entries(path = nil, identifier = nil)
+ path ||= ''
+ identifier = (identifier and identifier.to_i > 0) ? identifier.to_i : 'HEAD'
+ entries = Entries.new
+ cmd = ['list', '--xml', "#{target(path)}@#{identifier}"]
+ xml_capture(cmd, force_encoding: true) do |doc|
+ doc.xpath('/lists/list/entry').each { |list| entries << extract_entry(list, path) }
+ end
+ entries.sort_by_name
+ end
+
+ def properties(path, identifier = nil)
+ # proplist xml output supported in svn 1.5.0 and higher
+ return nil unless client_version_above?([1, 5, 0])
+
+ identifier = (identifier and identifier.to_i > 0) ? identifier.to_i : 'HEAD'
+ cmd = ['proplist', '--verbose', '--xml', "#{target(path)}@#{identifier}"]
+ properties = {}
+ xml_capture(cmd, force_encoding: true) do |doc|
+ doc.xpath('/properties/target/property').each do |prop|
+ properties[prop['name']] = prop.text
+ end
+ end
+
+ properties
+ end
+
+ def revisions(path = nil, identifier_from = nil, identifier_to = nil, options = {})
+ revisions = Revisions.new
+ fetch_revision_entries(identifier_from, identifier_to, options, path) do |logentry|
+ paths = logentry.xpath('paths/path').map { |entry| build_path(entry) }
+ paths.sort! { |x, y| x[:path] <=> y[:path] }
+
+ r = extract_revision(logentry)
+ r.paths = paths
+
+ revisions << r
+ end
+ revisions
+ end
+
+ def diff(path, identifier_from, identifier_to = nil, _type = 'inline')
+ path ||= ''
+
+ identifier_from = numeric_identifier(identifier_from)
+ identifier_to = numeric_identifier(identifier_to, identifier_from - 1)
+
+ cmd = ['diff', '-r', "#{identifier_to}:#{identifier_from}",
+ "#{target(path)}@#{identifier_from}"]
+ capture_svn(cmd).lines.map(&:chomp)
+ end
+
+ def numeric_identifier(identifier, default = '')
+ if identifier && identifier.to_i > 0
+ identifier.to_i
+ else
+ default
+ end
+ end
+
+ def cat(path, identifier = nil)
+ identifier = (identifier and identifier.to_i > 0) ? identifier.to_i : 'HEAD'
+ cmd = ['cat', "#{target(path)}@#{identifier}"]
+ capture_svn(cmd)
+ end
+
+ def annotate(path, identifier = nil)
+ identifier = (identifier and identifier.to_i > 0) ? identifier.to_i : 'HEAD'
+ cmd = ['blame', "#{target(path)}@#{identifier}"]
+ blame = Annotate.new
+ popen3(cmd) do |io, _|
+ io.each_line do |line|
+ next unless line =~ %r{^\s*(\d+)\s*(\S+)\s(.*)$}
+ blame.add_line($3.rstrip, Revision.new(identifier: $1.to_i, author: $2.strip))
+ end
+ end
+ blame
+ end
+
+ private
+
+ ##
+ # Builds the SVM command arguments around the given parameters
+ # Appends to the parameter:
+ # --username, --password if specified for this repository
+ # --no-auth-cache force re-authentication
+ # --non-interactive avoid prompts
+ def build_svn_cmd(args)
+ if @login.present?
+ args.push('--username', shell_quote(@login))
+ args.push('--password', shell_quote(@password)) if @password.present?
+ end
+
+ args.push('--no-auth-cache', '--non-interactive')
+ end
+
+ def xml_capture(cmd, opts = {})
+ output = capture_svn(cmd, opts)
+ doc = Nokogiri::XML(output)
+
+ # Yield helper methods instead of doc
+ yield doc
+ end
+
+ def extract_entry(entry, path)
+ revision = extract_revision(entry.at_xpath('commit'))
+ kind, size, name = parse_entry(entry)
+
+ # Skip directory if there is no commit date (usually that
+ # means that we don't have read access to it)
+ return if kind == 'dir' && revision.time.nil?
+
+ Entry.new(
+ name: URI.unescape(name),
+ path: ((path.empty? ? '' : "#{path}/") + name),
+ kind: kind,
+ size: size.empty? ? nil : size.to_i,
+ lastrev: revision
+ )
+ end
+
+ def parse_entry(entry)
+ kind = entry['kind']
+ size = entry.xpath('size').text
+ name = entry.xpath('name').text
+
+ [kind, size, name]
+ end
+
+ def build_path(entry)
+ {
+ action: entry['action'],
+ path: entry.text,
+ from_path: entry['copyfrom-path'],
+ from_revision: entry['copyfrom-rev']
+ }
+ end
+
+ def extract_revision(commit_node)
+ # We may be unauthorized to read the commit date
+ date =
+ begin
+ Time.parse(commit_node.xpath('date').text).localtime
+ rescue ArgumentError
+ nil
+ end
+
+ Revision.new(
+ identifier: commit_node['revision'],
+ time: date,
+ message: commit_node.xpath('msg').text,
+ author: commit_node.xpath('author').text
+ )
+ end
+
+ def fetch_revision_entries(identifier_from, identifier_to, options, path, &block)
+ path ||= ''
+ identifier_from = numeric_identifier(identifier_from, 'HEAD')
+ identifier_to = numeric_identifier(identifier_to, 1)
+ cmd = ['log', '--xml', '-r', "#{identifier_from}:#{identifier_to}"]
+ cmd << '--verbose' if options[:with_paths]
+ cmd << '--limit' << options[:limit].to_s if options[:limit]
+ cmd << target(path)
+ xml_capture(cmd, force_encoding: true) do |doc|
+ doc.xpath('/log/logentry').each &block
+ end
+ end
+
+ def target(path = '')
+ base = path.match(/\A\//) ? root_url : url
+ uri = "#{base}/#{path}"
+ URI.escape(URI.escape(uri), '[]')
+ # shell_quote(uri.gsub(/[?<>\*]/, ''))
+ end
+
+ ##
+ # Builds the full git arguments from the parameters
+ # and return the executed stdout as a string
+ def capture_svn(args, opt = {})
+ cmd = build_svn_cmd(args)
+ output = capture_out(cmd)
+
+ if opt[:force_encoding] && output.respond_to?(:force_encoding)
+ output.force_encoding('UTF-8')
+ end
+
+ output
+ end
+
+ ##
+ # Builds the full git arguments from the parameters
+ # and calls the given block with in, out, err, thread
+ # from +Open3#popen3+.
+ def popen3(args, &block)
+ cmd = build_svn_cmd(args)
+ super(cmd) do |_stdin, stdout, stderr, wait_thr|
+ block.call(stdout, stderr)
+
+ process = wait_thr.value
+ return process.exitstatus == 0
+ end
+ end
+ end
+ end
+ end
+end
diff --git a/lib/open_project/scm/exceptions.rb b/lib/open_project/scm/exceptions.rb
new file mode 100644
index 00000000000..afcf6f49784
--- /dev/null
+++ b/lib/open_project/scm/exceptions.rb
@@ -0,0 +1,94 @@
+#-- encoding: UTF-8
+#-- copyright
+# OpenProject is a project management system.
+# Copyright (C) 2012-2015 the OpenProject Foundation (OPF)
+#
+# This program is free software; you can redistribute it and/or
+# modify it under the terms of the GNU General Public License version 3.
+#
+# OpenProject is a fork of ChiliProject, which is a fork of Redmine. The copyright follows:
+# Copyright (C) 2006-2013 Jean-Philippe Lang
+# Copyright (C) 2010-2013 the ChiliProject Team
+#
+# This program is free software; you can redistribute it and/or
+# modify it under the terms of the GNU General Public License
+# as published by the Free Software Foundation; either version 2
+# of the License, or (at your option) any later version.
+#
+# This program is distributed in the hope that it will be useful,
+# but WITHOUT ANY WARRANTY; without even the implied warranty of
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+# GNU General Public License for more details.
+#
+# You should have received a copy of the GNU General Public License
+# along with this program; if not, write to the Free Software
+# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
+#
+# See doc/COPYRIGHT.rdoc for more details.
+#++
+module OpenProject
+ module Scm
+ module Exceptions
+ # Parent SCM exception class
+ class ScmError < StandardError
+ end
+
+ # Exception marking an error in the repository build process
+ class RepositoryBuildError < ScmError
+ end
+
+ # Exception marking an error in the repository teardown process
+ class RepositoryUnlinkError < ScmError
+ end
+
+ # Exception marking an error in the execution of a local command.
+ class CommandFailed < ScmError
+ attr_reader :program
+ attr_reader :message
+ attr_reader :stderr
+
+ # Create a +CommandFailed+ exception for the executed program (e.g., 'svn'),
+ # and a meaningful error message
+ #
+ # If the operation throws an exception or the operation we rethrow a
+ # +ShellError+ with a meaningful error message.
+ def initialize(program, message, stderr = nil)
+ @program = program
+ @message = message
+ @stderr = stderr
+ end
+
+ def to_s
+ s = "CommandFailed(#{@program}) -> #{@message}"
+ s << "(#{@stderr})" unless @stderr.nil?
+
+ s
+ end
+ end
+
+ # a localized exception raised when SCM could be accessed
+ class ScmUnavailable < ScmError
+ def initialize(key = 'unavailable')
+ @error = I18n.t("repositories.errors.#{key}")
+ end
+
+ def to_s
+ @error
+ end
+ end
+
+ # raised if SCM could not be accessed due to authorization failure
+ class ScmUnauthorized < ScmUnavailable
+ def initialize
+ super('unauthorized')
+ end
+ end
+ # raised when encountering an empty (bare) repository
+ class ScmEmpty < ScmUnavailable
+ def initialize
+ super('empty_repository')
+ end
+ end
+ end
+ end
+end
diff --git a/lib/open_project/scm/manageable_repository.rb b/lib/open_project/scm/manageable_repository.rb
new file mode 100644
index 00000000000..9e0725efc52
--- /dev/null
+++ b/lib/open_project/scm/manageable_repository.rb
@@ -0,0 +1,114 @@
+#-- encoding: UTF-8
+#-- copyright
+# OpenProject is a project management system.
+# Copyright (C) 2012-2015 the OpenProject Foundation (OPF)
+#
+# This program is free software; you can redistribute it and/or
+# modify it under the terms of the GNU General Public License version 3.
+#
+# OpenProject is a fork of ChiliProject, which is a fork of Redmine. The copyright follows:
+# Copyright (C) 2006-2013 Jean-Philippe Lang
+# Copyright (C) 2010-2013 the ChiliProject Team
+#
+# This program is free software; you can redistribute it and/or
+# modify it under the terms of the GNU General Public License
+# as published by the Free Software Foundation; either version 2
+# of the License, or (at your option) any later version.
+#
+# This program is distributed in the hope that it will be useful,
+# but WITHOUT ANY WARRANTY; without even the implied warranty of
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+# GNU General Public License for more details.
+#
+# You should have received a copy of the GNU General Public License
+# along with this program; if not, write to the Free Software
+# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
+#
+# See doc/COPYRIGHT.rdoc for more details.
+#++
+
+module OpenProject
+ module Scm
+ module ManageableRepository
+ def self.included(base)
+ base.extend(ClassMethods)
+ end
+
+ module ClassMethods
+ ##
+ # We let SCM vendor implementation define their own
+ # types (e.g., for differences in the management of
+ # local vs. remote repositories).
+ #
+ # But if they are manageable by OpenProject, they must
+ # expose this type through +available_types+.
+ def managed_type
+ :managed
+ end
+
+ ##
+ # Reads from configuration whether new repositories of this kind
+ # may be managed from OpenProject.
+ def manageable?
+ !(disabled_types.include?(managed_type) || managed_root.nil?)
+ end
+
+ ##
+ # Returns the managed root for this repository vendor
+ def managed_root
+ scm_config[:manages]
+ end
+ end
+
+ ##
+ #
+ def manageable?
+ self.class.manageable?
+ end
+
+ ##
+ # Determines whether this repository IS currently managed
+ # by openproject
+ def managed?
+ scm_type.to_sym == self.class.managed_type
+ end
+
+ ##
+ # Allows descendants to perform actions
+ # with the given repository after the managed
+ # repository has been written to file system.
+ def managed_repo_created(_args)
+ nil
+ end
+
+ ##
+ # Returns the absolute path to the repository
+ # under the +managed_root+ path defined in the configuration
+ # of this adapter.
+ # Used only in the creation of a repository, at a later point
+ # in time, it is referred to in the root_url
+ def managed_repository_path
+ File.join(self.class.managed_root, repository_identifier)
+ end
+
+ ##
+ # Returns the access url to the repository
+ # May be overridden by descendants
+ # Used only in the creation of a repository, at a later point
+ # in time, it is referred to in the url
+ def managed_repository_url
+ "file://#{managed_repository_path}"
+ end
+
+ protected
+
+ ##
+ # Repository relative path from scm managed root.
+ # Will be overridden by including models to, e.g.,
+ # append '.git' to that path.
+ def repository_identifier
+ project.identifier
+ end
+ end
+ end
+end
diff --git a/lib/redmine/scm/base.rb b/lib/open_project/scm/manager.rb
similarity index 73%
rename from lib/redmine/scm/base.rb
rename to lib/open_project/scm/manager.rb
index 7181b008cc1..d168b3f331c 100644
--- a/lib/redmine/scm/base.rb
+++ b/lib/open_project/scm/manager.rb
@@ -27,31 +27,36 @@
# See doc/COPYRIGHT.rdoc for more details.
#++
-module Redmine
+module OpenProject
module Scm
- class Base
+ class Manager
class << self
- def all
- @scms
+ def registered
+ @scms ||= {}
end
- def configured
- @scms.select do |scm_name|
- klass = Repository.const_get(scm_name)
+ def vendors
+ @scms.keys
+ end
- klass.configured?
- end
+ ##
+ # Returns all enabled repositories as a Hash
+ # { vendor_name: repository class constant }
+ def enabled
+ registered.select { |scm| Setting.enabled_scm.include?(scm) }
end
# Add a new SCM adapter and repository
def add(scm_name)
- @scms ||= []
- @scms << scm_name
+ # Force model lookup to avoid
+ # const errors later on.
+ klass = Repository.const_get(scm_name)
+ registered[scm_name] = klass
end
# Remove a SCM adapter from Redmine's list of supported scms
def delete(scm_name)
- @scms.delete(scm_name)
+ registered.delete(scm_name)
end
end
end
diff --git a/lib/open_project/version.rb b/lib/open_project/version.rb
index b5c04d91b74..5771430fe57 100644
--- a/lib/open_project/version.rb
+++ b/lib/open_project/version.rb
@@ -31,8 +31,8 @@ require 'rexml/document'
module OpenProject
module VERSION #:nodoc:
- MAJOR = 4
- MINOR = 3
+ MAJOR = 5
+ MINOR = 0
PATCH = 0
TINY = PATCH # Redmine compat
@@ -48,7 +48,7 @@ module OpenProject
#
# 2.0.0debian-2
def self.special
- ''
+ '-alpha'
end
def self.revision
diff --git a/lib/plugins/acts_as_event/lib/acts_as_event.rb b/lib/plugins/acts_as_event/lib/acts_as_event.rb
index cafe3c49cab..b0456733e9d 100644
--- a/lib/plugins/acts_as_event/lib/acts_as_event.rb
+++ b/lib/plugins/acts_as_event/lib/acts_as_event.rb
@@ -92,12 +92,11 @@ module Redmine
end
end
- # Returns the mail adresses of users that should be notified
+ # Returns users that should be notified
def recipients
notified = []
notified = project.notified_users if project
- notified.reject! { |user| !visible?(user) }
- notified.map(&:mail)
+ notified.select { |user| visible?(user) }
end
module ClassMethods
diff --git a/lib/plugins/acts_as_journalized/lib/journal_changes.rb b/lib/plugins/acts_as_journalized/lib/journal_changes.rb
new file mode 100644
index 00000000000..4dfa90fe241
--- /dev/null
+++ b/lib/plugins/acts_as_journalized/lib/journal_changes.rb
@@ -0,0 +1,86 @@
+#-- copyright
+# OpenProject is a project management system.
+# Copyright (C) 2012-2015 the OpenProject Foundation (OPF)
+#
+# This program is free software; you can redistribute it and/or
+# modify it under the terms of the GNU General Public License version 3.
+#
+# OpenProject is a fork of ChiliProject, which is a fork of Redmine. The copyright follows:
+# Copyright (C) 2006-2013 Jean-Philippe Lang
+# Copyright (C) 2010-2013 the ChiliProject Team
+#
+# This program is free software; you can redistribute it and/or
+# modify it under the terms of the GNU General Public License
+# as published by the Free Software Foundation; either version 2
+# of the License, or (at your option) any later version.
+#
+# This program is distributed in the hope that it will be useful,
+# but WITHOUT ANY WARRANTY; without even the implied warranty of
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+# GNU General Public License for more details.
+#
+# You should have received a copy of the GNU General Public License
+# along with this program; if not, write to the Free Software
+# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
+#
+# See doc/COPYRIGHT.rdoc for more details.
+#++
+
+#-- encoding: UTF-8
+module JournalChanges
+ def get_changes
+ return @changes if @changes
+ return {} if data.nil?
+
+ @changes = HashWithIndifferentAccess.new
+
+ if predecessor.nil?
+ @changes = data.journaled_attributes
+ .reject { |_, new_value| new_value.nil? }
+ .inject({}) { |result, (attribute, new_value)|
+ result[attribute] = [nil, new_value]
+ result
+ }
+ else
+ normalized_new_data = JournalManager.normalize_newlines(data.journaled_attributes)
+ normalized_old_data = JournalManager.normalize_newlines(predecessor.data.journaled_attributes)
+
+ normalized_new_data.select { |attribute, new_value|
+ # we dont record changes for changes from nil to empty strings and vice versa
+ old_value = normalized_old_data[attribute]
+ new_value != old_value && (new_value.present? || old_value.present?)
+ }.each do |attribute, new_value|
+ @changes[attribute] = [normalized_old_data[attribute], new_value]
+ end
+ end
+
+ @changes.merge!(get_association_changes predecessor, 'attachable', 'attachments', :attachment_id, :filename)
+ @changes.merge!(get_association_changes predecessor, 'customizable', 'custom_fields', :custom_field_id, :value)
+ end
+
+ def get_association_changes(predecessor, journal_association, association, key, value)
+ changes = {}
+ journal_assoc_name = "#{journal_association}_journals"
+
+ if predecessor.nil?
+ send(journal_assoc_name).each_with_object(changes) { |associated_journal, h|
+ changed_attribute = "#{association}_#{associated_journal.send(key)}"
+ new_value = associated_journal.send(value)
+ h[changed_attribute] = [nil, new_value]
+ }
+ else
+ new_journals = send(journal_assoc_name).map(&:attributes)
+ old_journals = predecessor.send(journal_assoc_name).map(&:attributes)
+
+ merged_journals = JournalManager.merge_reference_journals_by_id new_journals,
+ old_journals,
+ key.to_s
+
+ changes.merge! JournalManager.added_references(merged_journals, association, value.to_s)
+ changes.merge! JournalManager.removed_references(merged_journals, association, value.to_s)
+ changes.merge! JournalManager.changed_references(merged_journals, association, value.to_s)
+ end
+
+ changes
+ end
+end
diff --git a/lib/plugins/acts_as_journalized/lib/redmine/acts/journalized/changes.rb b/lib/plugins/acts_as_journalized/lib/redmine/acts/journalized/changes.rb
index f480c521b2f..1792b61575a 100644
--- a/lib/plugins/acts_as_journalized/lib/redmine/acts/journalized/changes.rb
+++ b/lib/plugins/acts_as_journalized/lib/redmine/acts/journalized/changes.rb
@@ -96,7 +96,7 @@ module Redmine::Acts::Journalized
backward ? chain.pop : chain.shift unless from_number == 1 || to_number == 1
chain.inject({}) do |changes, journal|
- changes.append_changes!(backward ? journal.changed_data.reverse_changes : journal.changed_data)
+ changes.append_changes!(backward ? journal.details.reverse_changes : journal.details)
end
end
diff --git a/lib/plugins/acts_as_journalized/lib/redmine/acts/journalized/creation.rb b/lib/plugins/acts_as_journalized/lib/redmine/acts/journalized/creation.rb
index c4a473f42fb..f5c41f49187 100644
--- a/lib/plugins/acts_as_journalized/lib/redmine/acts/journalized/creation.rb
+++ b/lib/plugins/acts_as_journalized/lib/redmine/acts/journalized/creation.rb
@@ -112,10 +112,10 @@ module Redmine::Acts::Journalized
# Try to find the real initial values
unless journals.empty?
journals[1..-1].each do |journal|
- unless journal.changed_data[name].nil?
+ unless journal.details[name].nil?
# Found the first change in journals
# Copy the first value as initial change value
- initial_changes[name] = journal.changed_data[name].first
+ initial_changes[name] = journal.details[name].first
break
end
end
@@ -179,9 +179,9 @@ module Redmine::Acts::Journalized
# Specifies the attributes used during journal creation. This is separated into its own
# method so that it can be overridden by the VestalVersions::Users feature.
def journal_attributes
- attributes = { journaled_id: id, activity_type: activity_type,
- changed_data: journal_changes, version: last_version + 1,
- notes: journal_notes, user_id: (journal_user.try(:id) || User.current.try(:id))
+ { journaled_id: id, activity_type: activity_type,
+ details: journal_changes, version: last_version + 1,
+ notes: journal_notes, user_id: (journal_user.try(:id) || User.current.try(:id))
}.merge(extra_journal_attributes || {})
end
end
diff --git a/lib/plugins/acts_as_journalized/lib/redmine/acts/journalized/deprecated.rb b/lib/plugins/acts_as_journalized/lib/redmine/acts/journalized/deprecated.rb
index a0b8fb48435..f23b21dbb50 100644
--- a/lib/plugins/acts_as_journalized/lib/redmine/acts/journalized/deprecated.rb
+++ b/lib/plugins/acts_as_journalized/lib/redmine/acts/journalized/deprecated.rb
@@ -55,8 +55,7 @@ module Redmine::Acts::Journalized
def recipients
notified = []
notified = project.notified_users if project
- notified.reject! { |user| !visible?(user) }
- notified.map(&:mail)
+ notified.select { |user| visible?(user) }
end
def current_journal
diff --git a/lib/plugins/acts_as_journalized/lib/redmine/acts/journalized/save_hooks.rb b/lib/plugins/acts_as_journalized/lib/redmine/acts/journalized/save_hooks.rb
index 44d8a74b355..b2849535a80 100644
--- a/lib/plugins/acts_as_journalized/lib/redmine/acts/journalized/save_hooks.rb
+++ b/lib/plugins/acts_as_journalized/lib/redmine/acts/journalized/save_hooks.rb
@@ -66,12 +66,21 @@ module Redmine::Acts::Journalized
add_journal = journals.empty? || JournalManager.changed?(self) || !@journal_notes.empty?
- JournalManager.add_journal self, @journal_user, @journal_notes if add_journal
+ journal = JournalManager.add_journal self, @journal_user, @journal_notes if add_journal
journals.select(&:new_record?).each do |journal|
save_journal_with_retry(journal)
end
+ if add_journal
+ OpenProject::Notifications.send('journal_created',
+ journal: journal,
+ send_notification: JournalManager.send_notification)
+ end
+
+ # Need to clear the notification setting after each usage otherwise it might be cached
+ JournalManager.reset_notification
+
@journal_user = nil
@journal_notes = nil
end
diff --git a/lib/plugins/acts_as_journalized/test/changes_test.rb b/lib/plugins/acts_as_journalized/test/changes_test.rb
index 23e0867a471..cabaaa946a3 100644
--- a/lib/plugins/acts_as_journalized/test/changes_test.rb
+++ b/lib/plugins/acts_as_journalized/test/changes_test.rb
@@ -34,7 +34,7 @@ class ChangesTest < Test::Unit::TestCase
setup do
@user = User.create(name: 'Steve Richert')
@user.update_attribute(:last_name, 'Jobs')
- @changes = @user.journals.last.changed_data
+ @changes = @user.journals.last.details
end
should 'be a hash' do
@@ -73,7 +73,7 @@ class ChangesTest < Test::Unit::TestCase
@user.first_name = 'Stephen'
model_changes = @user.changed_data
@user.save
- changes = @user.journals.last.changed_data
+ changes = @user.journals.last.details
assert_equal model_changes, changes
end
end
diff --git a/lib/plugins/acts_as_journalized/test/creation_test.rb b/lib/plugins/acts_as_journalized/test/creation_test.rb
index 2bbd140af06..e957983c96c 100644
--- a/lib/plugins/acts_as_journalized/test/creation_test.rb
+++ b/lib/plugins/acts_as_journalized/test/creation_test.rb
@@ -81,7 +81,7 @@ class CreationTest < Test::Unit::TestCase
should 'not contain Rails timestamps' do
%w(created_at created_on updated_at updated_on).each do |timestamp|
- assert_does_not_contain @user.journals.last.changed_data.keys, timestamp
+ assert_does_not_contain @user.journals.last.details.keys, timestamp
end
end
@@ -93,7 +93,7 @@ class CreationTest < Test::Unit::TestCase
end
should 'only contain the specified columns' do
- assert_equal @only, @user.journals.last.changed_data.keys
+ assert_equal @only, @user.journals.last.details.keys
end
teardown do
@@ -110,7 +110,7 @@ class CreationTest < Test::Unit::TestCase
should 'not contain the specified columns' do
@except.each do |column|
- assert_does_not_contain @user.journals.last.changed_data.keys, column
+ assert_does_not_contain @user.journals.last.details.keys, column
end
end
@@ -128,7 +128,7 @@ class CreationTest < Test::Unit::TestCase
end
should 'respect only the :only options' do
- assert_equal @only, @user.journals.last.changed_data.keys
+ assert_equal @only, @user.journals.last.details.keys
end
teardown do
diff --git a/lib/plugins/acts_as_watchable/lib/acts_as_watchable.rb b/lib/plugins/acts_as_watchable/lib/acts_as_watchable.rb
index 7235a3dbf65..ff211bd81ee 100644
--- a/lib/plugins/acts_as_watchable/lib/acts_as_watchable.rb
+++ b/lib/plugins/acts_as_watchable/lib/acts_as_watchable.rb
@@ -146,12 +146,10 @@ module Redmine
watcher_user_ids.any? { |uid| uid == user.id }))
end
- # Returns an array of watchers' email addresses
+ # Returns an array of watchers
def watcher_recipients
notified = watcher_users.active.where(['mail_notification != ?', 'none'])
- notified.select! do |user| possible_watcher?(user) end
-
- notified.map(&:mail).compact
+ notified.select { |user| possible_watcher?(user) }
end
module ClassMethods; end
diff --git a/lib/redmine/default_data/loader.rb b/lib/redmine/default_data/loader.rb
deleted file mode 100644
index 3d23b5014b0..00000000000
--- a/lib/redmine/default_data/loader.rb
+++ /dev/null
@@ -1,313 +0,0 @@
-#-- encoding: UTF-8
-#-- copyright
-# OpenProject is a project management system.
-# Copyright (C) 2012-2015 the OpenProject Foundation (OPF)
-#
-# This program is free software; you can redistribute it and/or
-# modify it under the terms of the GNU General Public License version 3.
-#
-# OpenProject is a fork of ChiliProject, which is a fork of Redmine. The copyright follows:
-# Copyright (C) 2006-2013 Jean-Philippe Lang
-# Copyright (C) 2010-2013 the ChiliProject Team
-#
-# This program is free software; you can redistribute it and/or
-# modify it under the terms of the GNU General Public License
-# as published by the Free Software Foundation; either version 2
-# of the License, or (at your option) any later version.
-#
-# This program is distributed in the hope that it will be useful,
-# but WITHOUT ANY WARRANTY; without even the implied warranty of
-# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
-# GNU General Public License for more details.
-#
-# You should have received a copy of the GNU General Public License
-# along with this program; if not, write to the Free Software
-# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
-#
-# See doc/COPYRIGHT.rdoc for more details.
-#++
-
-module Redmine
- module DefaultData
- class DataAlreadyLoaded < Exception; end
-
- module Loader
- include Redmine::I18n
-
- class << self
- # Returns true if no data is already loaded in the database
- # otherwise false
- def no_data?
- !Role.where(builtin: 0).first &&
- !::Type.where(is_standard: false).first &&
- !Status.first &&
- !Enumeration.first
- end
-
- # Loads the default data
- # Raises a RecordNotSaved exception if something goes wrong
- def load(lang = nil)
- raise DataAlreadyLoaded.new('Some configuration data is already loaded.') unless no_data?
- set_language_if_valid(lang)
-
- Role.transaction do
- # Roles
- manager = Role.create! name: l(:default_role_manager),
- position: 3
- manager.permissions = manager.setable_permissions.map(&:name)
- manager.save!
-
- member = Role.create! name: l(:default_role_member),
- position: 4,
- permissions: [:view_work_packages,
- :export_work_packages,
- :add_work_packages,
- :move_work_packages,
- :edit_work_packages,
- :add_work_package_notes,
- :edit_own_work_package_notes,
- :manage_work_package_relations,
- :manage_subtasks,
- :manage_public_queries,
- :save_queries,
- :view_work_package_watchers,
- :add_work_package_watchers,
- :delete_work_package_watchers,
- :view_calendar,
- :comment_news,
- :log_time,
- :view_time_entries,
- :edit_own_time_entries,
- :view_project_associations,
- :view_timelines,
- :edit_timelines,
- :delete_timelines,
- :view_reportings,
- :edit_reportings,
- :delete_reportings,
- :manage_wiki,
- :manage_wiki_menu,
- :rename_wiki_pages,
- :change_wiki_parent_page,
- :delete_wiki_pages,
- :view_wiki_pages,
- :export_wiki_pages,
- :view_wiki_edits,
- :edit_wiki_pages,
- :delete_wiki_pages_attachments,
- :protect_wiki_pages,
- :list_attachments,
- :add_messages,
- :edit_own_messages,
- :delete_own_messages,
- :browse_repository,
- :view_changesets,
- :commit_access,
- :view_commit_author_statistics]
-
- reader = Role.create! name: l(:default_role_reader),
- position: 5,
- permissions: [:view_work_packages,
- :add_work_package_notes,
- :edit_own_work_package_notes,
- :save_queries,
- :view_calendar,
- :comment_news,
- :view_project_associations,
- :view_timelines,
- :view_reportings,
- :view_wiki_pages,
- :export_wiki_pages,
- :view_wiki_edits,
- :edit_wiki_pages,
- :list_attachments,
- :add_messages,
- :edit_own_messages,
- :delete_own_messages,
- :browse_repository,
- :view_changesets]
-
- Role.non_member.update_attributes name: l(:default_role_non_member),
- permissions: [:view_work_packages,
- :view_calendar,
- :comment_news,
- :browse_repository,
- :view_changesets,
- :view_wiki_pages]
-
- Role.anonymous.update_attributes name: l(:default_role_anonymous),
- permissions: [:view_work_packages,
- :browse_repository,
- :view_changesets,
- :view_wiki_pages]
-
- # Colors
- colors_list = PlanningElementTypeColor.colors
- colors = Hash[*(colors_list.map { |color|
- color.save
- color.reload
- [color.name.to_sym, color.id]
- }).flatten]
-
- # Types
- task = ::Type.create! name: l(:default_type_task),
- color_id: colors[:Mint],
- is_default: false,
- is_in_roadmap: true,
- in_aggregation: true,
- is_milestone: false,
- position: 1
-
- deliverable = ::Type.create! name: l(:default_type_deliverable),
- is_default: false,
- color_id: colors[:Orange],
- is_in_roadmap: true,
- in_aggregation: true,
- is_milestone: false,
- position: 2
-
- milestone = ::Type.create! name: l(:default_type_milestone),
- is_default: true,
- color_id: colors[:Purple],
- is_in_roadmap: false,
- in_aggregation: true,
- is_milestone: true,
- position: 3
-
- phase = ::Type.create! name: l(:default_type_phase),
- is_default: true,
- color_id: colors[:Lime],
- is_in_roadmap: false,
- in_aggregation: true,
- is_milestone: false,
- position: 4
-
- bug = ::Type.create! name: l(:default_type_bug),
- is_default: false,
- color_id: colors[:'Red-bright'],
- is_in_roadmap: true,
- in_aggregation: true,
- is_milestone: false,
- position: 5
-
- feature = ::Type.create! name: l(:default_type_feature),
- is_default: false,
- color_id: colors[:Blue],
- is_in_roadmap: true,
- in_aggregation: true,
- is_milestone: false,
- position: 6
-
- none = ::Type.standard_type
-
- # Issue statuses
- new = Status.create!(name: l(:default_status_new),
- is_closed: false,
- is_default: true,
- position: 1)
- specified = Status.create!(name: l(:default_status_specified),
- is_closed: false,
- is_default: false,
- position: 2)
- confirmed = Status.create!(name: l(:default_status_confirmed),
- is_closed: false,
- is_default: false,
- position: 3)
- to_be_scheduled = Status.create!(name: l(:default_status_to_be_scheduled),
- is_closed: false,
- is_default: false,
- position: 4)
- scheduled = Status.create!(name: l(:default_status_scheduled),
- is_closed: false,
- is_default: false,
- position: 5)
- in_progress = Status.create!(name: l(:default_status_in_progress),
- is_closed: false,
- is_default: false,
- position: 6)
- tested = Status.create!(name: l(:default_status_tested),
- is_closed: false,
- is_default: false,
- position: 7)
- on_hold = Status.create!(name: l(:default_status_on_hold),
- is_closed: false,
- is_default: false,
- position: 8)
- rejected = Status.create!(name: l(:default_status_rejected),
- is_closed: true,
- is_default: false,
- position: 9)
- closed = Status.create!(name: l(:default_status_closed),
- is_closed: true,
- is_default: false,
- position: 10)
-
- # Workflow - Each type has its own workflow
- workflows = { task.id => [new, in_progress, on_hold, rejected, closed],
- deliverable.id => [new, specified, in_progress, on_hold, rejected, closed],
- none.id => [new, in_progress, rejected, closed],
- milestone.id => [new, to_be_scheduled, scheduled, in_progress, on_hold, rejected, closed],
- phase.id => [new, to_be_scheduled, scheduled, in_progress, on_hold, rejected, closed],
- bug.id => [new, confirmed, in_progress, tested, on_hold, rejected, closed],
- feature.id => [new, specified, confirmed, in_progress, tested, on_hold, rejected, closed] }
- workflows.each do |type_id, statuses_for_type|
- statuses_for_type.each { |old_status|
- statuses_for_type.each { |new_status|
- [manager.id, member.id].each { |role_id|
- Workflow.create!(type_id: type_id,
- role_id: role_id,
- old_status_id: old_status.id,
- new_status_id: new_status.id)
- }
- }
- }
- end
-
- # Enumerations
-
- IssuePriority.create!(name: l(:default_priority_low), position: 1)
- IssuePriority.create!(name: l(:default_priority_normal), position: 2, is_default: true)
- IssuePriority.create!(name: l(:default_priority_high), position: 3)
- IssuePriority.create!(name: l(:default_priority_immediate), position: 5)
-
- TimeEntryActivity.create!(name: l(:default_activity_management), position: 1, is_default: true)
- TimeEntryActivity.create!(name: l(:default_activity_design), position: 2)
- TimeEntryActivity.create!(name: l(:default_activity_development), position: 3)
- TimeEntryActivity.create!(name: l(:default_activity_testing), position: 4)
- TimeEntryActivity.create!(name: l(:default_activity_Support), position: 5)
- TimeEntryActivity.create!(name: l(:default_activity_Other), position: 6)
-
- ReportedProjectStatus.create!(name: l(:default_reported_project_status_green), is_default: true)
- ReportedProjectStatus.create!(name: l(:default_reported_project_status_amber), is_default: false)
- ReportedProjectStatus.create!(name: l(:default_reported_project_status_red), is_default: false)
-
- # Project types
-
- ProjectType.create!(name: l(:default_project_type_customer))
- ProjectType.create!(name: l(:default_project_type_internal))
-
- reported_status_ids = ReportedProjectStatus.all.map(&:id)
- ProjectType.all.each do |project|
- project.update_attributes(reported_project_status_ids: reported_status_ids)
- end
-
- Setting['notified_events'] = ['work_package_added', \
- 'work_package_updated',\
- 'work_package_note_added',\
- 'work_package_updated',\
- 'status_updated',\
- 'work_package_updated',\
- 'work_package_priority_updated',\
- 'work_package_updated',\
- 'news_added', 'news_comment_added',\
- 'file_added',\
- 'message_posted',\
- 'wiki_content_added',\
- 'wiki_content_updated']
- end
- true
- end
- end
- end
- end
-end
diff --git a/lib/redmine/i18n.rb b/lib/redmine/i18n.rb
index ec4b5832bca..823bd5854dc 100644
--- a/lib/redmine/i18n.rb
+++ b/lib/redmine/i18n.rb
@@ -109,8 +109,10 @@ module Redmine
end
end
+ ##
+ # Returns the given language if it is valid or nil otherwise.
def find_language(lang)
- valid_languages.detect { |l| l =~ /#{lang}/i }
+ valid_languages.detect { |l| l =~ /#{lang}/i } if lang.present?
end
def set_language_if_valid(lang)
diff --git a/lib/redmine/menu_manager/menu_helper.rb b/lib/redmine/menu_manager/menu_helper.rb
index 527cb328981..d11c8d77beb 100644
--- a/lib/redmine/menu_manager/menu_helper.rb
+++ b/lib/redmine/menu_manager/menu_helper.rb
@@ -201,7 +201,6 @@ module Redmine::MenuManager::MenuHelper
html_options[:title] ||= caption
html_options[:lang] = menu_item_locale(item)
-
link_to link_text, url, html_options
end
diff --git a/lib/redmine/scm/adapters/abstract_adapter.rb b/lib/redmine/scm/adapters/abstract_adapter.rb
deleted file mode 100644
index cf9aef9ef41..00000000000
--- a/lib/redmine/scm/adapters/abstract_adapter.rb
+++ /dev/null
@@ -1,365 +0,0 @@
-#-- encoding: UTF-8
-#-- copyright
-# OpenProject is a project management system.
-# Copyright (C) 2012-2015 the OpenProject Foundation (OPF)
-#
-# This program is free software; you can redistribute it and/or
-# modify it under the terms of the GNU General Public License version 3.
-#
-# OpenProject is a fork of ChiliProject, which is a fork of Redmine. The copyright follows:
-# Copyright (C) 2006-2013 Jean-Philippe Lang
-# Copyright (C) 2010-2013 the ChiliProject Team
-#
-# This program is free software; you can redistribute it and/or
-# modify it under the terms of the GNU General Public License
-# as published by the Free Software Foundation; either version 2
-# of the License, or (at your option) any later version.
-#
-# This program is distributed in the hope that it will be useful,
-# but WITHOUT ANY WARRANTY; without even the implied warranty of
-# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
-# GNU General Public License for more details.
-#
-# You should have received a copy of the GNU General Public License
-# along with this program; if not, write to the Free Software
-# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
-#
-# See doc/COPYRIGHT.rdoc for more details.
-#++
-
-require 'cgi'
-
-module Redmine
- module Scm
- module Adapters
- class CommandFailed < StandardError #:nodoc:
- end
-
- class AbstractAdapter #:nodoc:
- class << self
- def client_command
- ''
- end
-
- # Returns the version of the scm client
- # Eg: [1, 5, 0] or [] if unknown
- def client_version
- []
- end
-
- # Returns the version string of the scm client
- # Eg: '1.5.0' or 'Unknown version' if unknown
- def client_version_string
- v = client_version || 'Unknown version'
- v.is_a?(Array) ? v.join('.') : v.to_s
- end
-
- # Returns true if the current client version is above
- # or equals the given one
- # If option is :unknown is set to true, it will return
- # true if the client version is unknown
- def client_version_above?(v, options = {})
- ((client_version <=> v) >= 0) || (client_version.empty? && options[:unknown])
- end
-
- def client_available
- true
- end
-
- def shell_quote(str)
- if Redmine::Platform.mswin?
- '"' + str.gsub(/"/, '\\"') + '"'
- else
- "'" + str.gsub(/'/, "'\"'\"'") + "'"
- end
- end
- end
-
- def initialize(url, root_url = nil, login = nil, password = nil,
- _path_encoding = nil)
- @url = url
- @login = login if login && !login.empty?
- @password = (password || '') if @login
- @root_url = root_url.blank? ? retrieve_root_url : root_url
- end
-
- def adapter_name
- 'Abstract'
- end
-
- def supports_cat?
- true
- end
-
- def supports_annotate?
- respond_to?('annotate')
- end
-
- def root_url
- @root_url
- end
-
- def url
- @url
- end
-
- # get info about the svn repository
- def info
- nil
- end
-
- # Returns the entry identified by path and revision identifier
- # or nil if entry doesn't exist in the repository
- def entry(path = nil, identifier = nil)
- parts = path.to_s.split(%r{[\/\\]}).select { |n| !n.blank? }
- search_path = parts[0..-2].join('/')
- search_name = parts[-1]
- if search_path.blank? && search_name.blank?
- # Root entry
- Entry.new(path: '', kind: 'dir')
- else
- # Search for the entry in the parent directory
- es = entries(search_path, identifier)
- es ? es.detect { |e| e.name == search_name } : nil
- end
- end
-
- # Returns an Entries collection
- # or nil if the given path doesn't exist in the repository
- def entries(_path = nil, _identifier = nil)
- nil
- end
-
- def branches
- nil
- end
-
- def tags
- nil
- end
-
- def default_branch
- nil
- end
-
- def properties(_path, _identifier = nil)
- nil
- end
-
- def revisions(_path = nil, _identifier_from = nil, _identifier_to = nil, _options = {})
- nil
- end
-
- def diff(_path, _identifier_from, _identifier_to = nil)
- nil
- end
-
- def cat(_path, _identifier = nil)
- nil
- end
-
- def with_leading_slash(path)
- path ||= ''
- (path[0, 1] != '/') ? "/#{path}" : path
- end
-
- def with_trailling_slash(path)
- path ||= ''
- (path[-1, 1] == '/') ? path : "#{path}/"
- end
-
- def without_leading_slash(path)
- path ||= ''
- path.gsub(%r{\A/+}, '')
- end
-
- def without_trailling_slash(path)
- path ||= ''
- (path[-1, 1] == '/') ? path[0..-2] : path
- end
-
- def shell_quote(str)
- self.class.shell_quote(str)
- end
-
- private
-
- def retrieve_root_url
- info = self.info
- info ? info.root_url : nil
- end
-
- def target(path)
- path ||= ''
- base = path.match(/\A\//) ? root_url : url
- shell_quote("#{base}/#{path}".gsub(/[?<>\*]/, ''))
- end
-
- def logger
- self.class.logger
- end
-
- def shellout(cmd, &block)
- self.class.shellout(cmd, &block)
- end
-
- def self.logger
- Rails.logger
- end
-
- def self.shellout(cmd, &block)
- logger.debug "Shelling out: #{strip_credential(cmd)}" if logger && logger.debug?
- if Rails.env == 'development'
- # Capture stderr when running in dev environment
- cmd = "#{cmd} 2>>#{Rails.root}/log/scm.stderr.log"
- end
- begin
- if RUBY_VERSION < '1.9'
- mode = 'r+'
- else
- mode = 'r+:ASCII-8BIT'
- end
- IO.popen(cmd, mode) do |io|
- io.close_write
- block.call(io) if block_given?
- end
- rescue Errno::ENOENT => e
- msg = strip_credential(e.message)
- # The command failed, log it and re-raise
- logger.error("SCM command failed, make sure that your SCM binary (eg. svn) is in PATH (#{ENV['PATH']}): #{strip_credential(cmd)}\n with: #{msg}")
- raise CommandFailed.new(msg)
- end
- end
-
- # Hides username/password in a given command
- def self.strip_credential(cmd)
- q = (Redmine::Platform.mswin? ? '"' : "'")
- cmd.to_s.gsub(/(\-\-(password|username))\s+(#{q}[^#{q}]+#{q}|[^#{q}]\S+)/, '\\1 xxxx')
- end
-
- def strip_credential(cmd)
- self.class.strip_credential(cmd)
- end
-
- def scm_encode(to, from, str)
- return nil if str.nil?
- return str if to == from
- begin
- str.to_s.encode(to, from)
- rescue Encoding::InvalidByteSequenceError, Encoding::UndefinedConversionError => err
- logger.error("failed to convert from #{from} to #{to}. #{err}")
- nil
- end
- end
- end
-
- class Entries < Array
- def sort_by_name
- sort {|x, y|
- if x.kind == y.kind
- x.name.to_s <=> y.name.to_s
- else
- x.kind <=> y.kind
- end
- }
- end
-
- def revisions
- revisions ||= Revisions.new(collect(&:lastrev).compact)
- end
- end
-
- class Info
- attr_accessor :root_url, :lastrev
- def initialize(attributes = {})
- self.root_url = attributes[:root_url] if attributes[:root_url]
- self.lastrev = attributes[:lastrev]
- end
- end
-
- class Entry
- attr_accessor :name, :path, :kind, :size, :lastrev
- def initialize(attributes = {})
- self.name = attributes[:name] if attributes[:name]
- self.path = attributes[:path] if attributes[:path]
- self.kind = attributes[:kind] if attributes[:kind]
- self.size = attributes[:size].to_i if attributes[:size]
- self.lastrev = attributes[:lastrev]
- end
-
- def is_file?
- 'file' == kind
- end
-
- def is_dir?
- 'dir' == kind
- end
-
- def is_text?
- Redmine::MimeType.is_type?('text', name)
- end
- end
-
- class Revisions < Array
- def latest
- sort {|x, y|
- unless x.time.nil? or y.time.nil?
- x.time <=> y.time
- else
- 0
- end
- }.last
- end
- end
-
- class Revision
- attr_accessor :scmid, :name, :author, :time, :message, :paths, :revision, :branch
- attr_writer :identifier
-
- def initialize(attributes = {})
- self.identifier = attributes[:identifier]
- self.scmid = attributes[:scmid]
- self.name = attributes[:name] || identifier
- self.author = attributes[:author]
- self.time = attributes[:time]
- self.message = attributes[:message] || ''
- self.paths = attributes[:paths]
- self.revision = attributes[:revision]
- self.branch = attributes[:branch]
- end
-
- # Returns the identifier of this revision; see also Changeset model
- def identifier
- (@identifier || revision).to_s
- end
-
- # Returns the readable identifier.
- def format_identifier
- identifier
- end
- end
-
- class Annotate
- attr_reader :lines, :revisions
-
- def initialize
- @lines = []
- @revisions = []
- end
-
- def add_line(line, revision)
- @lines << line
- @revisions << revision
- end
-
- def content
- content = lines.join("\n")
- end
-
- def empty?
- lines.empty?
- end
- end
- end
- end
-end
diff --git a/lib/redmine/scm/adapters/filesystem_adapter.rb b/lib/redmine/scm/adapters/filesystem_adapter.rb
deleted file mode 100644
index ab6c108277a..00000000000
--- a/lib/redmine/scm/adapters/filesystem_adapter.rb
+++ /dev/null
@@ -1,122 +0,0 @@
-#-- encoding: UTF-8
-#-- copyright
-# OpenProject is a project management system.
-# Copyright (C) 2012-2015 the OpenProject Foundation (OPF)
-#
-# This program is free software; you can redistribute it and/or
-# modify it under the terms of the GNU General Public License version 3.
-#
-# OpenProject is a fork of ChiliProject, which is a fork of Redmine. The copyright follows:
-# Copyright (C) 2006-2013 Jean-Philippe Lang
-# Copyright (C) 2010-2013 the ChiliProject Team
-#
-# This program is free software; you can redistribute it and/or
-# modify it under the terms of the GNU General Public License
-# as published by the Free Software Foundation; either version 2
-# of the License, or (at your option) any later version.
-#
-# This program is distributed in the hope that it will be useful,
-# but WITHOUT ANY WARRANTY; without even the implied warranty of
-# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
-# GNU General Public License for more details.
-#
-# You should have received a copy of the GNU General Public License
-# along with this program; if not, write to the Free Software
-# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
-#
-# See doc/COPYRIGHT.rdoc for more details.
-#++
-
-require 'redmine/scm/adapters/abstract_adapter'
-require 'find'
-
-module Redmine
- module Scm
- module Adapters
- class FilesystemAdapter < AbstractAdapter
- class << self
- def client_available
- true
- end
- end
-
- def initialize(url, _root_url = nil, _login = nil, _password = nil,
- path_encoding = nil)
- @url = with_trailling_slash(url)
- @path_encoding = path_encoding || 'UTF-8'
- end
-
- def format_path_ends(path, leading = true, trailling = true)
- path = leading ? with_leading_slash(path) :
- without_leading_slash(path)
- trailling ? with_trailling_slash(path) :
- without_trailling_slash(path)
- end
-
- def info
- info = Info.new(root_url: target,
- lastrev: nil
- )
- info
- rescue CommandFailed
- return nil
- end
-
- def entries(path = '', _identifier = nil)
- entries = Entries.new
- trgt_utf8 = target(path)
- trgt = scm_encode(@path_encoding, 'UTF-8', trgt_utf8)
- Dir.new(trgt).each do |e1|
- e_utf8 = scm_encode('UTF-8', @path_encoding, e1)
- next if e_utf8.blank?
- relative_path_utf8 = format_path_ends(
- (format_path_ends(path, false, true) + e_utf8), false, false)
- t1_utf8 = target(relative_path_utf8)
- t1 = scm_encode(@path_encoding, 'UTF-8', t1_utf8)
- relative_path = scm_encode(@path_encoding, 'UTF-8', relative_path_utf8)
- e1 = scm_encode(@path_encoding, 'UTF-8', e_utf8)
- if File.exist?(t1) and # paranoid test
- %w{file directory}.include?(File.ftype(t1)) and # avoid special types
- not File.basename(e1).match(/\A\.+\z/) # avoid . and ..
- p1 = File.readable?(t1) ? relative_path : ''
- utf_8_path = scm_encode('UTF-8', @path_encoding, p1)
- entries <<
- Entry.new(name: scm_encode('UTF-8', @path_encoding, File.basename(e1)),
- # below : list unreadable files, but dont link them.
- path: utf_8_path,
- kind: (File.directory?(t1) ? 'dir' : 'file'),
- size: (File.directory?(t1) ? nil : [File.size(t1)].pack('l').unpack('L').first),
- lastrev:
- Revision.new(time: (File.mtime(t1)))
- )
- end
- end
- entries.sort_by_name
- rescue => err
- logger.error "scm: filesystem: error: #{err.message}"
- raise CommandFailed.new(err.message)
- end
-
- def cat(path, _identifier = nil)
- p = scm_encode(@path_encoding, 'UTF-8', target(path))
- File.new(p, 'rb').read
- rescue => err
- logger.error "scm: filesystem: error: #{err.message}"
- raise CommandFailed.new(err.message)
- end
-
- private
-
- # AbstractAdapter::target is implicitly made to quote paths.
- # Here we do not shell-out, so we do not want quotes.
- def target(path = nil)
- # Prevent the use of ..
- if path and !path.match(/(^|\/)\.\.(\/|$)/)
- return "#{url}#{without_leading_slash(path)}"
- end
- url
- end
- end
- end
- end
-end
diff --git a/lib/redmine/scm/adapters/git_adapter.rb b/lib/redmine/scm/adapters/git_adapter.rb
deleted file mode 100644
index 8f0d667d2e8..00000000000
--- a/lib/redmine/scm/adapters/git_adapter.rb
+++ /dev/null
@@ -1,369 +0,0 @@
-#-- encoding: UTF-8
-#-- copyright
-# OpenProject is a project management system.
-# Copyright (C) 2012-2015 the OpenProject Foundation (OPF)
-#
-# This program is free software; you can redistribute it and/or
-# modify it under the terms of the GNU General Public License version 3.
-#
-# OpenProject is a fork of ChiliProject, which is a fork of Redmine. The copyright follows:
-# Copyright (C) 2006-2013 Jean-Philippe Lang
-# Copyright (C) 2010-2013 the ChiliProject Team
-#
-# This program is free software; you can redistribute it and/or
-# modify it under the terms of the GNU General Public License
-# as published by the Free Software Foundation; either version 2
-# of the License, or (at your option) any later version.
-#
-# This program is distributed in the hope that it will be useful,
-# but WITHOUT ANY WARRANTY; without even the implied warranty of
-# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
-# GNU General Public License for more details.
-#
-# You should have received a copy of the GNU General Public License
-# along with this program; if not, write to the Free Software
-# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
-#
-# See doc/COPYRIGHT.rdoc for more details.
-#++
-
-require 'redmine/scm/adapters/abstract_adapter'
-
-module Redmine
- module Scm
- module Adapters
- class GitAdapter < AbstractAdapter
- SCM_GIT_REPORT_LAST_COMMIT = true
-
- # Git executable name
- GIT_BIN = OpenProject::Configuration['scm_git_command'] || 'git'
-
- # raised if scm command exited with error, e.g. unknown revision.
- class ScmCommandAborted < CommandFailed; end
-
- class << self
- def client_command
- @@bin ||= GIT_BIN
- end
-
- def sq_bin
- @@sq_bin ||= shell_quote(GIT_BIN)
- end
-
- def client_version
- @@client_version ||= (scm_command_version || [])
- end
-
- def client_available
- !client_version.empty?
- end
-
- def scm_command_version
- scm_version = scm_version_from_command_line.dup
- if scm_version.respond_to?(:force_encoding)
- scm_version.force_encoding('ASCII-8BIT')
- end
- if m = scm_version.match(%r{\A(.*?)((\d+\.)+\d+)})
- m[2].scan(%r{\d+}).map(&:to_i)
- end
- end
-
- def scm_version_from_command_line
- shellout("#{sq_bin} --version --no-color") { |io| io.read }.to_s
- end
- end
-
- def initialize(url, root_url = nil, login = nil, password = nil, path_encoding = nil)
- super
- @path_encoding = path_encoding || 'UTF-8'
- @flag_report_last_commit = SCM_GIT_REPORT_LAST_COMMIT
- end
-
- def info
- Info.new(root_url: url, lastrev: lastrev('', nil))
- rescue
- nil
- end
-
- def branches
- return @branches if @branches
- @branches = []
- cmd_args = %w|branch --no-color|
- scm_cmd(*cmd_args) do |io|
- io.each_line do |line|
- @branches << line.match('\s*\*?\s*(.*)$')[1]
- end
- end
- @branches.sort!
- rescue ScmCommandAborted
- nil
- end
-
- def tags
- return @tags if @tags
- cmd_args = %w|tag|
- scm_cmd(*cmd_args) do |io|
- @tags = io.readlines.sort!.map(&:strip)
- end
- rescue ScmCommandAborted
- nil
- end
-
- def default_branch
- bras = branches
- return nil if bras.nil?
- bras.include?('master') ? 'master' : bras.first
- end
-
- def entries(path = nil, identifier = nil)
- path ||= ''
- p = scm_encode(@path_encoding, 'UTF-8', path)
- entries = Entries.new
- cmd_args = %w|ls-tree -l|
- cmd_args << "HEAD:#{p}" if identifier.nil?
- cmd_args << "#{identifier}:#{p}" if identifier
- scm_cmd(*cmd_args) do |io|
- io.each_line do |line|
- e = line.chomp.to_s
- if e =~ /^\d+\s+(\w+)\s+([0-9a-f]{40})\s+([0-9-]+)\t(.+)$/
- type = $1
- sha = $2
- size = $3
- name = $4
- if name.respond_to?(:force_encoding)
- name.force_encoding(@path_encoding)
- end
- full_path = p.empty? ? name : "#{p}/#{name}"
- n = scm_encode('UTF-8', @path_encoding, name)
- full_p = scm_encode('UTF-8', @path_encoding, full_path)
- entries << Entry.new(name: n,
- path: full_p,
- kind: (type == 'tree') ? 'dir' : 'file',
- size: (type == 'tree') ? nil : size,
- lastrev: @flag_report_last_commit ? lastrev(full_path, identifier) : Revision.new
- ) unless entries.detect { |entry| entry.name == name }
- end
- end
- end
- entries.sort_by_name
- rescue ScmCommandAborted
- nil
- end
-
- def lastrev(path, rev)
- return nil if path.nil?
- cmd_args = %w|log --no-color --encoding=UTF-8 --date=iso --pretty=fuller --no-merges -n 1|
- cmd_args << rev if rev
- cmd_args << '--' << path unless path.empty?
- lines = []
- scm_cmd(*cmd_args) do |io| lines = io.readlines end
- begin
- id = lines[0].split[1]
- author = lines[1].match('Author:\s+(.*)$')[1]
- time = Time.parse(lines[4].match('CommitDate:\s+(.*)$')[1])
-
- Revision.new(
- identifier: id,
- scmid: id,
- author: author,
- time: time,
- message: nil,
- paths: nil
- )
- rescue NoMethodError => e
- logger.error("The revision '#{path}' has a wrong format")
- return nil
- end
- rescue ScmCommandAborted
- nil
- end
-
- def revisions(path, identifier_from, identifier_to, options = {})
- revisions = Revisions.new
- cmd_args = %w|log --no-color --encoding=UTF-8 --raw --date=iso --pretty=fuller|
- cmd_args << '--reverse' if options[:reverse]
- cmd_args << '--all' if options[:all]
- cmd_args << '-n' << "#{options[:limit].to_i}" if options[:limit]
- from_to = ''
- from_to << "#{identifier_from}.." if identifier_from
- from_to << "#{identifier_to}" if identifier_to
- cmd_args << from_to if !from_to.empty?
- cmd_args << "--since=#{options[:since].strftime('%Y-%m-%d %H:%M:%S')}" if options[:since]
- cmd_args << '--' << scm_encode(@path_encoding, 'UTF-8', path) if path && !path.empty?
-
- scm_cmd *cmd_args do |io|
- files = []
- changeset = {}
- parsing_descr = 0 # 0: not parsing desc or files, 1: parsing desc, 2: parsing files
-
- io.each_line do |line|
- if line =~ /^commit ([0-9a-f]{40})$/
- key = 'commit'
- value = $1
- if parsing_descr == 1 || parsing_descr == 2
- parsing_descr = 0
- revision = Revision.new(
- identifier: changeset[:commit],
- scmid: changeset[:commit],
- author: changeset[:author],
- time: Time.parse(changeset[:date]),
- message: changeset[:description],
- paths: files
- )
- if block_given?
- yield revision
- else
- revisions << revision
- end
- changeset = {}
- files = []
- end
- changeset[:commit] = $1
- elsif (parsing_descr == 0) && line =~ /^(\w+):\s*(.*)$/
- key = $1
- value = $2
- if key == 'Author'
- changeset[:author] = value
- elsif key == 'CommitDate'
- changeset[:date] = value
- end
- elsif (parsing_descr == 0) && line.chomp.to_s == ''
- parsing_descr = 1
- changeset[:description] = ''
- elsif (parsing_descr == 1 || parsing_descr == 2) && line =~ /^:\d+\s+\d+\s+[0-9a-f.]+\s+[0-9a-f.]+\s+(\w)\t(.+)$/
- parsing_descr = 2
- fileaction = $1
- filepath = $2
- p = scm_encode('UTF-8', @path_encoding, filepath)
- files << { action: fileaction, path: p }
- elsif (parsing_descr == 1 || parsing_descr == 2) && line =~ /^:\d+\s+\d+\s+[0-9a-f.]+\s+[0-9a-f.]+\s+(\w)\d+\s+(\S+)\t(.+)$/
- parsing_descr = 2
- fileaction = $1
- filepath = $3
- p = scm_encode('UTF-8', @path_encoding, filepath)
- files << { action: fileaction, path: p }
- elsif (parsing_descr == 1) && line.chomp.to_s == ''
- parsing_descr = 2
- elsif (parsing_descr == 1)
- changeset[:description] << line[4..-1]
- end
- end
-
- if changeset[:commit]
- revision = Revision.new(
- identifier: changeset[:commit],
- scmid: changeset[:commit],
- author: changeset[:author],
- time: Time.parse(changeset[:date]),
- message: changeset[:description],
- paths: files
- )
-
- if block_given?
- yield revision
- else
- revisions << revision
- end
- end
- end
- revisions
- rescue ScmCommandAborted
- revisions
- end
-
- def diff(path, identifier_from, identifier_to = nil)
- path ||= ''
- cmd_args = []
- if identifier_to
- cmd_args << 'diff' << '--no-color' << identifier_to << identifier_from
- else
- cmd_args << 'show' << '--no-color' << identifier_from
- end
- cmd_args << '--' << scm_encode(@path_encoding, 'UTF-8', path) unless path.empty?
- diff = []
- scm_cmd *cmd_args do |io|
- io.each_line do |line|
- diff << line
- end
- end
- diff
- rescue ScmCommandAborted
- nil
- end
-
- def annotate(path, identifier = nil)
- identifier = 'HEAD' if identifier.blank?
- cmd_args = %w|blame|
- cmd_args << '-p' << identifier << '--' << scm_encode(@path_encoding, 'UTF-8', path)
- blame = Annotate.new
- content = nil
- scm_cmd(*cmd_args) do |io| io.binmode; content = io.read end
- # git annotates binary files
- if content.respond_to?('is_binary_data?') && content.is_binary_data? # Ruby 1.8.x and <1.9.2
- return nil
- elsif content.respond_to?(:force_encoding) && (content.dup.force_encoding('UTF-8') != content.dup.force_encoding('BINARY')) # Ruby 1.9.2
- # TODO: need to handle edge cases of non-binary content that isn't UTF-8
- return nil
- end
- identifier = ''
- # git shows commit author on the first occurrence only
- authors_by_commit = {}
- content.split("\n").each do |line|
- if line =~ /^([0-9a-f]{39,40})\s.*/
- identifier = $1
- elsif line =~ /^author (.+)/
- authors_by_commit[identifier] = $1.strip
- elsif line =~ /^\t(.*)/
- blame.add_line($1, Revision.new(
- identifier: identifier,
- author: authors_by_commit[identifier]))
- identifier = ''
- author = ''
- end
- end
- blame
- rescue ScmCommandAborted
- nil
- end
-
- def cat(path, identifier = nil)
- if identifier.nil?
- identifier = 'HEAD'
- end
- cmd_args = %w|show --no-color|
- cmd_args << "#{identifier}:#{scm_encode(@path_encoding, 'UTF-8', path)}"
- cat = nil
- scm_cmd(*cmd_args) do |io|
- io.binmode
- cat = io.read
- end
- cat
- rescue ScmCommandAborted
- nil
- end
-
- class Revision < Redmine::Scm::Adapters::Revision
- # Returns the readable identifier
- def format_identifier
- identifier[0, 8]
- end
- end
-
- def scm_cmd(*args, &block)
- repo_path = root_url || url
- full_args = [GIT_BIN, '--git-dir', repo_path]
- if self.class.client_version_above?([1, 7, 2])
- full_args << '-c' << 'core.quotepath=false'
- end
- full_args += args
- ret = shellout(full_args.map { |e| shell_quote e.to_s }.join(' '), &block)
- if $? && $?.exitstatus != 0
- raise ScmCommandAborted, "git exited with non-zero status: #{$?.exitstatus}"
- end
- ret
- end
- private :scm_cmd
- end
- end
- end
-end
diff --git a/lib/redmine/scm/adapters/subversion_adapter.rb b/lib/redmine/scm/adapters/subversion_adapter.rb
deleted file mode 100644
index cfaed779001..00000000000
--- a/lib/redmine/scm/adapters/subversion_adapter.rb
+++ /dev/null
@@ -1,293 +0,0 @@
-#-- encoding: UTF-8
-#-- copyright
-# OpenProject is a project management system.
-# Copyright (C) 2012-2015 the OpenProject Foundation (OPF)
-#
-# This program is free software; you can redistribute it and/or
-# modify it under the terms of the GNU General Public License version 3.
-#
-# OpenProject is a fork of ChiliProject, which is a fork of Redmine. The copyright follows:
-# Copyright (C) 2006-2013 Jean-Philippe Lang
-# Copyright (C) 2010-2013 the ChiliProject Team
-#
-# This program is free software; you can redistribute it and/or
-# modify it under the terms of the GNU General Public License
-# as published by the Free Software Foundation; either version 2
-# of the License, or (at your option) any later version.
-#
-# This program is distributed in the hope that it will be useful,
-# but WITHOUT ANY WARRANTY; without even the implied warranty of
-# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
-# GNU General Public License for more details.
-#
-# You should have received a copy of the GNU General Public License
-# along with this program; if not, write to the Free Software
-# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
-#
-# See doc/COPYRIGHT.rdoc for more details.
-#++
-
-require 'redmine/scm/adapters/abstract_adapter'
-require 'uri'
-
-module Redmine
- module Scm
- module Adapters
- class SubversionAdapter < AbstractAdapter
- # SVN executable name
- SVN_BIN = OpenProject::Configuration['scm_subversion_command'] || 'svn'
-
- class << self
- def client_command
- @@bin ||= SVN_BIN
- end
-
- def sq_bin
- @@sq_bin ||= shell_quote(SVN_BIN)
- end
-
- def client_version
- @@client_version ||= (svn_binary_version || [])
- end
-
- def client_available
- !client_version.empty?
- end
-
- def svn_binary_version
- scm_version = scm_version_from_command_line.dup
- if scm_version.respond_to?(:force_encoding)
- scm_version.force_encoding('ASCII-8BIT')
- end
- if m = scm_version.match(%r{\A(.*?)((\d+\.)+\d+)})
- m[2].scan(%r{\d+}).map(&:to_i)
- end
- end
-
- def scm_version_from_command_line
- shellout("#{sq_bin} --version") { |io| io.read }.to_s
- end
- end
-
- # Get info about the svn repository
- def info
- cmd = "#{self.class.sq_bin} info --xml #{target}"
- cmd << credentials_string
- info = nil
- shellout(cmd) do |io|
- output = io.read
- if output.respond_to?(:force_encoding)
- output.force_encoding('UTF-8')
- end
- begin
- doc = ActiveSupport::XmlMini.parse(output)
- # root_url = doc.elements["info/entry/repository/root"].text
- info = Info.new(root_url: doc['info']['entry']['repository']['root']['__content__'],
- lastrev: Revision.new(
- identifier: doc['info']['entry']['commit']['revision'],
- time: Time.parse(doc['info']['entry']['commit']['date']['__content__']).localtime,
- author: (doc['info']['entry']['commit']['author'] ? doc['info']['entry']['commit']['author']['__content__'] : '')
- )
- )
- rescue
- end
- end
- return nil if $? && $?.exitstatus != 0
- info
- rescue CommandFailed
- return nil
- end
-
- # Returns an Entries collection
- # or nil if the given path doesn't exist in the repository
- def entries(path = nil, identifier = nil)
- path ||= ''
- identifier = (identifier and identifier.to_i > 0) ? identifier.to_i : 'HEAD'
- entries = Entries.new
- cmd = "#{self.class.sq_bin} list --xml #{target(path)}@#{identifier}"
- cmd << credentials_string
- shellout(cmd) do |io|
- output = io.read
- if output.respond_to?(:force_encoding)
- output.force_encoding('UTF-8')
- end
- begin
- doc = ActiveSupport::XmlMini.parse(output)
- each_xml_element(doc['lists']['list'], 'entry') do |entry|
- commit = entry['commit']
- commit_date = commit['date']
- # Skip directory if there is no commit date (usually that
- # means that we don't have read access to it)
- next if entry['kind'] == 'dir' && commit_date.nil?
- name = entry['name']['__content__']
- entries << Entry.new(name: URI.unescape(name),
- path: ((path.empty? ? '' : "#{path}/") + name),
- kind: entry['kind'],
- size: ((s = entry['size']) ? s['__content__'].to_i : nil),
- lastrev: Revision.new(
- identifier: commit['revision'],
- time: Time.parse(commit_date['__content__'].to_s).localtime,
- author: ((a = commit['author']) ? a['__content__'] : nil)
- )
- )
- end
- rescue => e
- logger.error("Error parsing svn output: #{e.message}")
- logger.error("Output was:\n #{output}")
- end
- end
- return nil if $? && $?.exitstatus != 0
- logger.debug("Found #{entries.size} entries in the repository for #{target(path)}") if logger && logger.debug?
- entries.sort_by_name
- end
-
- def properties(path, identifier = nil)
- # proplist xml output supported in svn 1.5.0 and higher
- return nil unless self.class.client_version_above?([1, 5, 0])
-
- identifier = (identifier and identifier.to_i > 0) ? identifier.to_i : 'HEAD'
- cmd = "#{self.class.sq_bin} proplist --verbose --xml #{target(path)}@#{identifier}"
- cmd << credentials_string
- properties = {}
- shellout(cmd) do |io|
- output = io.read
- if output.respond_to?(:force_encoding)
- output.force_encoding('UTF-8')
- end
- begin
- doc = ActiveSupport::XmlMini.parse(output)
- each_xml_element(doc['properties']['target'], 'property') do |property|
- properties[property['name']] = property['__content__'].to_s
- end
- rescue
- end
- end
- return nil if $? && $?.exitstatus != 0
- properties
- end
-
- def revisions(path = nil, identifier_from = nil, identifier_to = nil, options = {})
- path ||= ''
- identifier_from = (identifier_from && identifier_from.to_i > 0) ? identifier_from.to_i : 'HEAD'
- identifier_to = (identifier_to && identifier_to.to_i > 0) ? identifier_to.to_i : 1
- revisions = Revisions.new
- cmd = "#{self.class.sq_bin} log --xml -r #{identifier_from}:#{identifier_to}"
- cmd << credentials_string
- cmd << ' --verbose ' if options[:with_paths]
- cmd << " --limit #{options[:limit].to_i}" if options[:limit]
- cmd << ' ' + target(path)
- shellout(cmd) do |io|
- output = io.read
- if output.respond_to?(:force_encoding)
- output.force_encoding('UTF-8')
- end
- begin
- doc = ActiveSupport::XmlMini.parse(output)
- each_xml_element(doc['log'], 'logentry') do |logentry|
- paths = []
- each_xml_element(logentry['paths'], 'path') do |path|
- paths << { action: path['action'],
- path: path['__content__'],
- from_path: path['copyfrom-path'],
- from_revision: path['copyfrom-rev']
- }
- end if logentry['paths'] && logentry['paths']['path']
- paths.sort! do |x, y| x[:path] <=> y[:path] end
-
- revisions << Revision.new(identifier: logentry['revision'],
- author: (logentry['author'] ? logentry['author']['__content__'] : ''),
- time: Time.parse(logentry['date']['__content__'].to_s).localtime,
- message: logentry['msg']['__content__'],
- paths: paths
- )
- end
- rescue
- end
- end
- return nil if $? && $?.exitstatus != 0
- revisions
- end
-
- def diff(path, identifier_from, identifier_to = nil, _type = 'inline')
- path ||= ''
- identifier_from = (identifier_from and identifier_from.to_i > 0) ? identifier_from.to_i : ''
-
- identifier_to = (identifier_to and identifier_to.to_i > 0) ? identifier_to.to_i : (identifier_from.to_i - 1)
-
- cmd = "#{self.class.sq_bin} diff -r "
- cmd << "#{identifier_to}:"
- cmd << "#{identifier_from}"
- cmd << " #{target(path)}@#{identifier_from}"
- cmd << credentials_string
- diff = []
- shellout(cmd) do |io|
- io.each_line do |line|
- diff << line
- end
- end
- return nil if $? && $?.exitstatus != 0
- diff
- end
-
- def cat(path, identifier = nil)
- identifier = (identifier and identifier.to_i > 0) ? identifier.to_i : 'HEAD'
- cmd = "#{self.class.sq_bin} cat #{target(path)}@#{identifier}"
- cmd << credentials_string
- cat = nil
- shellout(cmd) do |io|
- io.binmode
- cat = io.read
- end
- return nil if $? && $?.exitstatus != 0
- cat
- end
-
- def annotate(path, identifier = nil)
- identifier = (identifier and identifier.to_i > 0) ? identifier.to_i : 'HEAD'
- cmd = "#{self.class.sq_bin} blame #{target(path)}@#{identifier}"
- cmd << credentials_string
- blame = Annotate.new
- shellout(cmd) do |io|
- io.each_line do |line|
- next unless line =~ %r{^\s*(\d+)\s*(\S+)\s(.*)$}
- blame.add_line($3.rstrip, Revision.new(identifier: $1.to_i, author: $2.strip))
- end
- end
- return nil if $? && $?.exitstatus != 0
- blame
- end
-
- private
-
- def credentials_string
- str = ''
- str << " --username #{shell_quote(@login)}" unless @login.blank?
- str << " --password #{shell_quote(@password)}" unless @login.blank? || @password.blank?
- str << ' --no-auth-cache --non-interactive'
- str
- end
-
- # Helper that iterates over the child elements of a xml node
- # MiniXml returns a hash when a single child is found or an array of hashes for multiple children
- def each_xml_element(node, name)
- if node && node[name]
- if node[name].is_a?(Hash)
- yield node[name]
- else
- node[name].each do |element|
- yield element
- end
- end
- end
- end
-
- def target(path = '')
- base = path.match(/\A\//) ? root_url : url
- uri = "#{base}/#{path}"
- uri = URI.escape(URI.escape(uri), '[]')
- shell_quote(uri.gsub(/[?<>\*]/, ''))
- end
- end
- end
- end
-end
diff --git a/lib/tasks/testing.rake b/lib/tasks/testing.rake
index 913cc4d8a1a..adfc2d5243e 100644
--- a/lib/tasks/testing.rake
+++ b/lib/tasks/testing.rake
@@ -48,20 +48,16 @@ namespace :test do
FileUtils.mkdir_p Rails.root + '/tmp/test'
end
- supported_scms = [:subversion, :git, :filesystem]
+ supported_scms = [:subversion, :git]
desc 'Creates a test subversion repository'
- task subversion: :create_dir do
- repo_path = 'tmp/test/subversion_repository'
- system "svnadmin create #{repo_path}"
- system "gunzip < spec/fixtures/repositories/subversion_repository.dump.gz | svnadmin load #{repo_path}"
- end
-
- (supported_scms - [:subversion]).each do |scm|
+ supported_scms.each do |scm|
desc "Creates a test #{scm} repository"
task scm => :create_dir do
+ repo_path = File.join(Rails.root, "tmp/test/#{scm}_repository")
+ FileUtils.mkdir_p repo_path
# system "gunzip < spec/fixtures/repositories/#{scm}_repository.tar.gz | tar -xv -C tmp/test"
- system "tar -xvz -C tmp/test -f spec/fixtures/repositories/#{scm}_repository.tar.gz"
+ system "tar -xvz -C #{repo_path} -f spec/fixtures/repositories/#{scm}_repository.tar.gz"
end
end
diff --git a/spec/app/services/scm/create_managed_repository_service_spec.rb b/spec/app/services/scm/create_managed_repository_service_spec.rb
new file mode 100644
index 00000000000..f0cafa6d48c
--- /dev/null
+++ b/spec/app/services/scm/create_managed_repository_service_spec.rb
@@ -0,0 +1,137 @@
+#-- encoding: UTF-8
+#-- copyright
+# OpenProject is a project management system.
+# Copyright (C) 2012-2015 the OpenProject Foundation (OPF)
+#
+# This program is free software; you can redistribute it and/or
+# modify it under the terms of the GNU General Public License version 3.
+#
+# OpenProject is a fork of ChiliProject, which is a fork of Redmine. The copyright follows:
+# Copyright (C) 2006-2013 Jean-Philippe Lang
+# Copyright (C) 2010-2013 the ChiliProject Team
+#
+# This program is free software; you can redistribute it and/or
+# modify it under the terms of the GNU General Public License
+# as published by the Free Software Foundation; either version 2
+# of the License, or (at your option) any later version.
+#
+# This program is distributed in the hope that it will be useful,
+# but WITHOUT ANY WARRANTY; without even the implied warranty of
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+# GNU General Public License for more details.
+#
+# You should have received a copy of the GNU General Public License
+# along with this program; if not, write to the Free Software
+# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
+#
+# See doc/COPYRIGHT.rdoc for more details.
+
+require 'spec_helper'
+
+describe Scm::CreateManagedRepositoryService do
+ let(:user) { FactoryGirl.build(:user) }
+ let(:project) { FactoryGirl.build(:project) }
+
+ let(:repository) { FactoryGirl.build(:repository_subversion) }
+ subject(:service) { Scm::CreateManagedRepositoryService.new(repository) }
+
+ let(:config) { {} }
+
+ before do
+ allow(OpenProject::Configuration).to receive(:[]).and_call_original
+ allow(OpenProject::Configuration).to receive(:[]).with('scm').and_return(config)
+ end
+
+ shared_examples 'does not create a filesystem repository' do
+ it 'does not create a filesystem repository' do
+ expect(repository.managed?).to be false
+ expect(service.call).to be false
+ end
+ end
+
+ context 'with no managed configuration' do
+ it_behaves_like 'does not create a filesystem repository'
+ end
+
+ context 'with managed repository' do
+ # Must not .create a managed repository, or it will call this service itself!
+ let(:repository) {
+ repo = Repository::Subversion.new(scm_type: :managed)
+ repo.project = project
+ repo
+ }
+
+ context 'but no managed config' do
+ it 'does not create a filesystem repository' do
+ expect(repository.managed?).to be true
+ expect(service.call).to be false
+ end
+ end
+ end
+
+ context 'with managed config' do
+ include_context 'with tmpdir'
+ let(:config) {
+ {
+ Subversion: { manages: File.join(tmpdir, 'svn') },
+ Git: { manages: File.join(tmpdir, 'git') }
+ }
+ }
+
+ let(:repository) {
+ repo = Repository::Subversion.new(scm_type: :managed)
+ repo.project = project
+ repo.configure(:managed, nil)
+ repo
+ }
+
+ before do
+ allow_any_instance_of(Scm::CreateRepositoryJob)
+ .to receive(:repository).and_return(repository)
+ end
+
+ it 'creates the repository' do
+ expect(service.call).to be true
+ expect(File.directory?(repository.root_url)).to be true
+ end
+
+ context 'with pre-existing path on filesystem' do
+ before do
+ allow(File).to receive(:directory?).and_return(true)
+ end
+
+ it 'does not create the repository' do
+ expect(service.call).to be false
+ expect(service.localized_rejected_reason)
+ .to eq(I18n.t('repositories.errors.exists_on_filesystem'))
+ end
+ end
+
+ context 'with a permission error occuring in the Job' do
+ before do
+ allow(Scm::CreateRepositoryJob)
+ .to receive(:new).and_raise(Errno::EACCES)
+ end
+
+ it 'returns the correct error' do
+ expect(service.call).to be false
+ expect(service.localized_rejected_reason)
+ .to eq(I18n.t('repositories.errors.path_permission_failed',
+ path: repository.root_url))
+ end
+ end
+
+ context 'with an OS error occuring in the Job' do
+ before do
+ allow(Scm::CreateRepositoryJob)
+ .to receive(:new).and_raise(Errno::ENOENT)
+ end
+
+ it 'returns the correct error' do
+ expect(service.call).to be false
+ expect(service.localized_rejected_reason)
+ .to include('An error occurred while creating the repository on filesystem')
+ end
+ end
+ end
+end
diff --git a/spec/app/services/scm/delete_managed_repository_service_spec.rb b/spec/app/services/scm/delete_managed_repository_service_spec.rb
new file mode 100644
index 00000000000..d867d4ceadd
--- /dev/null
+++ b/spec/app/services/scm/delete_managed_repository_service_spec.rb
@@ -0,0 +1,112 @@
+#-- encoding: UTF-8
+#-- copyright
+# OpenProject is a project management system.
+# Copyright (C) 2012-2015 the OpenProject Foundation (OPF)
+#
+# This program is free software; you can redistribute it and/or
+# modify it under the terms of the GNU General Public License version 3.
+#
+# OpenProject is a fork of ChiliProject, which is a fork of Redmine. The copyright follows:
+# Copyright (C) 2006-2013 Jean-Philippe Lang
+# Copyright (C) 2010-2013 the ChiliProject Team
+#
+# This program is free software; you can redistribute it and/or
+# modify it under the terms of the GNU General Public License
+# as published by the Free Software Foundation; either version 2
+# of the License, or (at your option) any later version.
+#
+# This program is distributed in the hope that it will be useful,
+# but WITHOUT ANY WARRANTY; without even the implied warranty of
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+# GNU General Public License for more details.
+#
+# You should have received a copy of the GNU General Public License
+# along with this program; if not, write to the Free Software
+# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
+#
+# See doc/COPYRIGHT.rdoc for more details.
+
+require 'spec_helper'
+
+describe Scm::DeleteManagedRepositoryService do
+ let(:user) { FactoryGirl.build(:user) }
+ let(:project) { FactoryGirl.build(:project) }
+
+ let(:repository) { FactoryGirl.build(:repository_subversion) }
+ subject(:service) { Scm::DeleteManagedRepositoryService.new(repository) }
+
+ let(:config) { {} }
+
+ before do
+ allow(OpenProject::Configuration).to receive(:[]).and_call_original
+ allow(OpenProject::Configuration).to receive(:[]).with('scm').and_return(config)
+ end
+
+ shared_examples 'does not delete the repository' do
+ it 'does not delete the repository' do
+ expect(repository.managed?).to be false
+ expect(service.call).to be false
+ end
+ end
+
+ context 'with no managed configuration' do
+ it_behaves_like 'does not delete the repository'
+ end
+
+ context 'with managed repository, but no config' do
+ let(:repository) { FactoryGirl.build(:repository_subversion, scm_type: :managed) }
+
+ it 'does allow to delete the repository' do
+ expect(repository.managed?).to be true
+ expect(service.call).to be true
+ end
+ end
+
+ context 'with managed repository and managed config' do
+ include_context 'with tmpdir'
+ let(:config) {
+ {
+ Subversion: { manages: File.join(tmpdir, 'svn') },
+ Git: { manages: File.join(tmpdir, 'git') }
+ }
+ }
+
+ let(:repository) {
+ repo = Repository::Subversion.new(scm_type: :managed)
+ repo.project = project
+ repo.configure(:managed, nil)
+
+ repo.save!
+ repo
+ }
+
+ before do
+ allow_any_instance_of(Scm::CreateRepositoryJob)
+ .to receive(:repository).and_return(repository)
+ end
+
+ it 'deletes the repository' do
+ expect(File.directory?(repository.root_url)).to be true
+ expect(service.call).to be true
+ expect(File.directory?(repository.root_url)).to be false
+ end
+
+ context 'and parent project' do
+ let(:parent) { FactoryGirl.create(:project) }
+ let(:project) { FactoryGirl.create(:project, parent: parent) }
+ let(:repo_path) {
+ Pathname.new(File.join(tmpdir, 'svn', project.identifier))
+ }
+
+ it 'does not delete anything but the repository itself' do
+ expect(service.call).to be true
+ path = Pathname.new(repository.root_url)
+ expect(path).to eq(repo_path)
+
+ expect(path.exist?).to be false
+ expect(path.parent.exist?).to be true
+ expect(path.parent.to_s).to eq(repository.class.managed_root)
+ end
+ end
+ end
+end
diff --git a/spec/app/services/scm/repository_factory_service_spec.rb b/spec/app/services/scm/repository_factory_service_spec.rb
new file mode 100644
index 00000000000..3c7f474d433
--- /dev/null
+++ b/spec/app/services/scm/repository_factory_service_spec.rb
@@ -0,0 +1,133 @@
+#-- encoding: UTF-8
+#-- copyright
+# OpenProject is a project management system.
+# Copyright (C) 2012-2015 the OpenProject Foundation (OPF)
+#
+# This program is free software; you can redistribute it and/or
+# modify it under the terms of the GNU General Public License version 3.
+#
+# OpenProject is a fork of ChiliProject, which is a fork of Redmine. The copyright follows:
+# Copyright (C) 2006-2013 Jean-Philippe Lang
+# Copyright (C) 2010-2013 the ChiliProject Team
+#
+# This program is free software; you can redistribute it and/or
+# modify it under the terms of the GNU General Public License
+# as published by the Free Software Foundation; either version 2
+# of the License, or (at your option) any later version.
+#
+# This program is distributed in the hope that it will be useful,
+# but WITHOUT ANY WARRANTY; without even the implied warranty of
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+# GNU General Public License for more details.
+#
+# You should have received a copy of the GNU General Public License
+# along with this program; if not, write to the Free Software
+# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
+#
+# See doc/COPYRIGHT.rdoc for more details.
+
+require 'spec_helper'
+
+describe Scm::RepositoryFactoryService do
+ let(:user) { FactoryGirl.build(:user) }
+ let(:project) { FactoryGirl.build(:project) }
+
+ let(:enabled_scms) { ['Subversion', 'Git'] }
+
+ let(:params_hash) { {} }
+ let(:params) { ActionController::Parameters.new params_hash }
+
+ subject(:service) { Scm::RepositoryFactoryService.new(project, params) }
+
+ before do
+ allow(Setting).to receive(:enabled_scm).and_return(enabled_scms)
+ end
+
+ context 'with empty hash' do
+ it 'should not build a repository' do
+ expect(service.build_temporary).not_to be true
+ expect(service.repository).to be_nil
+ end
+ end
+
+ context 'with valid vendor' do
+ let(:params_hash) {
+ { scm_vendor: 'Subversion' }
+ }
+
+ it 'should allow temporary build repository' do
+ expect(service.build_temporary).to be true
+ expect(service.repository).not_to be_nil
+ end
+
+ it 'should not allow to persist a repository' do
+ expect { service.build_and_save }
+ .to raise_error(ActionController::ParameterMissing)
+
+ expect(service.repository).to be_nil
+ end
+ end
+
+ context 'with invalid vendor' do
+ let(:params_hash) {
+ { scm_vendor: 'NotSubversion', scm_type: 'foo' }
+ }
+
+ it 'should not allow to temporary build repository' do
+ expect { service.build_temporary }.not_to raise_error
+
+ expect(service.repository).to be_nil
+ expect(service.build_error).to include('The SCM vendor NotSubversion is disabled')
+ end
+
+ it 'should not allow to persist a repository' do
+ expect { service.build_temporary }.not_to raise_error
+
+ expect(service.repository).to be_nil
+ expect(service.build_error).to include('The SCM vendor NotSubversion is disabled')
+ end
+ end
+
+ context 'with vendor and type' do
+ let(:params_hash) {
+ { scm_vendor: 'Subversion', scm_type: 'existing' }
+ }
+
+ it 'should not allow to persist a repository without URL' do
+ expect(service.build_and_save).not_to be true
+
+ expect(service.repository).to be_nil
+ expect(service.build_error).to include("URL can't be blank")
+ end
+ end
+
+ context 'with invalid hash' do
+ let(:params_hash) {
+ {
+ scm_vendor: 'Subversion', scm_type: 'existing',
+ repository: { url: '/tmp/foo.svn' }
+ }
+ }
+
+ it 'should not allow to persist a repository URL' do
+ expect(service.build_and_save).not_to be true
+
+ expect(service.repository).to be_nil
+ expect(service.build_error).to include('URL is invalid')
+ end
+ end
+
+ context 'with valid hash' do
+ let(:params_hash) {
+ {
+ scm_vendor: 'Subversion', scm_type: 'existing',
+ repository: { url: 'file:///tmp/foo.svn' }
+ }
+ }
+
+ it 'should allow to persist a repository without URL' do
+ expect(service.build_and_save).to be true
+ expect(service.repository).to be_kind_of(Repository::Subversion)
+ end
+ end
+end
diff --git a/spec/controllers/messages_controller_spec.rb b/spec/controllers/messages_controller_spec.rb
index bcfca5f439c..9be3267f587 100644
--- a/spec/controllers/messages_controller_spec.rb
+++ b/spec/controllers/messages_controller_spec.rb
@@ -69,7 +69,7 @@ describe MessagesController, type: :controller do
describe '#journal' do
let(:attachment_id) { "attachments_#{Message.last.attachments.first.id}" }
- subject { Message.last.journals.last.changed_data }
+ subject { Message.last.journals.last.details }
it { is_expected.to have_key attachment_id }
@@ -142,13 +142,13 @@ describe MessagesController, type: :controller do
end
describe '#key' do
- subject { message.journals.last.changed_data }
+ subject { message.journals.last.details }
it { is_expected.to have_key attachment_id }
end
describe '#value' do
- subject { message.journals.last.changed_data[attachment_id].last }
+ subject { message.journals.last.details[attachment_id].last }
it { is_expected.to eq(filename) }
end
@@ -179,13 +179,13 @@ describe MessagesController, type: :controller do
let(:attachment_id) { "attachments_#{attachment.id}" }
describe '#key' do
- subject { message.journals.last.changed_data }
+ subject { message.journals.last.details }
it { is_expected.to have_key attachment_id }
end
describe '#value' do
- subject { message.journals.last.changed_data[attachment_id].first }
+ subject { message.journals.last.details[attachment_id].first }
it { is_expected.to eq(filename) }
end
diff --git a/spec/controllers/repositories_controller_spec.rb b/spec/controllers/repositories_controller_spec.rb
index ad72a5552a6..4172d6fcf34 100644
--- a/spec/controllers/repositories_controller_spec.rb
+++ b/spec/controllers/repositories_controller_spec.rb
@@ -38,19 +38,17 @@ describe RepositoriesController, type: :controller do
FactoryGirl.create(:user, member_in_project: project,
member_through_role: role)
}
- let(:repository) { FactoryGirl.create(:repository, project: project) }
-
- let(:user) do
- FactoryGirl.create(:user, member_in_project: project,
- member_through_role: role)
- end
+ let (:url) { 'file:///tmp/something/does/not/exist.svn' }
let(:repository) do
- allow(Setting).to receive(:enabled_scm).and_return(['Filesystem'])
- repo = FactoryGirl.build_stubbed(:repository,
+ allow(Setting).to receive(:enabled_scm).and_return(['Subversion'])
+ repo = FactoryGirl.build_stubbed(:repository_subversion,
+ scm_type: 'local',
+ url: url,
project: project)
allow(repo).to receive(:default_branch).and_return('master')
allow(repo).to receive(:branches).and_return(['master'])
+ allow(repo).to receive(:save).and_return(true)
repo
end
@@ -60,7 +58,7 @@ describe RepositoriesController, type: :controller do
allow(project).to receive(:repository).and_return(repository)
end
- describe '#edit' do
+ describe 'manages the repository' do
let(:role) { FactoryGirl.create(:role, permissions: [:manage_repository]) }
before do
@@ -68,85 +66,134 @@ describe RepositoriesController, type: :controller do
allow(controller).to receive(:authorize).and_return(true)
end
- shared_examples_for 'successful response' do
+ shared_examples_for 'successful settings response' do
it 'is successful' do
expect(response).to be_success
end
it 'renders the template' do
- expect(response).to render_template 'projects/settings/repository'
+ expect(response).to render_template 'repositories/settings/repository_form'
end
end
- context 'GET' do
+ context 'with #edit' do
before do
xhr :get, :edit
end
- it_behaves_like 'successful response'
+ it_behaves_like 'successful settings response'
end
- context 'POST' do
+ context 'with #destroy' do
before do
- allow(repository).to receive(:save).and_return(true)
-
- xhr :post, :edit
+ allow(repository).to receive(:destroy).and_return(true)
+ xhr :delete, :destroy
end
- it_behaves_like 'successful response'
- end
- end
-
- describe 'commits per author graph' do
- before do
- get :graph, project_id: project.identifier, graph: 'commits_per_author'
- end
-
- context 'requested by an authorized user' do
- let(:role) {
- FactoryGirl.create(:role, permissions: [:browse_repository,
- :view_commit_author_statistics])
- }
-
- it 'should be successful' do
- expect(response).to be_success
- end
-
- it 'should have the right content type' do
- expect(response.content_type).to eq('image/svg+xml')
+ it 'redirects to settings' do
+ expect(response).to redirect_to(settings_project_path(project, tab: 'repository'))
end
end
- context 'requested by an unauthorized user' do
- let(:role) { FactoryGirl.create(:role, permissions: [:browse_repository]) }
+ context 'with #update' do
+ before do
+ xhr :put, :update
+ end
- it 'should return 403' do
- expect(response.code).to eq('403')
+ it_behaves_like 'successful settings response'
+ end
+
+ context 'with #create' do
+ before do
+ xhr :post,
+ :create,
+ scm_vendor: 'Subversion',
+ scm_type: 'local',
+ url: 'file:///tmp/repo.svn/'
+ end
+
+ it 'renders a JS redirect' do
+ path = "\/projects\/#{project.identifier}/settings\/repository"
+ expect(response.body).to match(/window\.location = '#{path}'/)
end
end
end
- describe 'stats' do
+ describe 'with empty repository' do
+ let(:role) { FactoryGirl.create(:role, permissions: [:browse_repository]) }
before do
- get :stats, project_id: project.identifier
+ allow(repository.scm)
+ .to receive(:check_availability!)
+ .and_raise(OpenProject::Scm::Exceptions::ScmEmpty)
end
- describe 'requested by a user with view_commit_author_statistics permission' do
- let(:role) {
- FactoryGirl.create(:role, permissions: [:browse_repository,
- :view_commit_author_statistics])
- }
-
- it 'show the commits per author graph' do
- expect(assigns(:show_commits_per_author)).to eq(true)
+ context 'with #show' do
+ before do
+ get :show, project_id: project.identifier
+ end
+ it 'renders an empty warning view' do
+ expect(response).to render_template 'repositories/empty'
+ expect(response.code).to eq('200')
end
end
+ end
- describe 'requested by a user without view_commit_author_statistics permission' do
- let(:role) { FactoryGirl.create(:role, permissions: [:browse_repository]) }
+ describe 'with filesystem repository' do
+ with_filesystem_repository('subversion', 'svn') do |repo_dir|
+ let(:url) { "file://#{repo_dir}" }
- it 'should NOT show the commits per author graph' do
- expect(assigns(:show_commits_per_author)).to eq(false)
+ describe 'commits per author graph' do
+ before do
+ get :graph, project_id: project.identifier, graph: 'commits_per_author'
+ end
+
+ context 'requested by an authorized user' do
+ let(:role) {
+ FactoryGirl.create(:role, permissions: [:browse_repository,
+ :view_commit_author_statistics])
+ }
+
+ it 'should be successful' do
+ expect(response).to be_success
+ end
+
+ it 'should have the right content type' do
+ expect(response.content_type).to eq('image/svg+xml')
+ end
+ end
+
+ context 'requested by an unauthorized user' do
+ let(:role) { FactoryGirl.create(:role, permissions: [:browse_repository]) }
+
+ it 'should return 403' do
+ expect(response.code).to eq('403')
+ end
+ end
+ end
+
+ describe 'stats' do
+ before do
+ get :stats, project_id: project.identifier
+ end
+
+ describe 'requested by a user with view_commit_author_statistics permission' do
+ let(:role) {
+ FactoryGirl.create(:role, permissions: [:browse_repository,
+ :view_commit_author_statistics])
+ }
+
+ it 'show the commits per author graph' do
+ expect(assigns(:show_commits_per_author)).to eq(true)
+ end
+ end
+
+ describe 'requested by a user without view_commit_author_statistics permission' do
+ let(:role) { FactoryGirl.create(:role, permissions: [:browse_repository]) }
+
+ it 'should NOT show the commits per author graph' do
+ expect(assigns(:show_commits_per_author)).to eq(false)
+ end
+ end
end
end
end
diff --git a/spec/controllers/sys_controller_spec.rb b/spec/controllers/sys_controller_spec.rb
index 0df73b3ac8c..8a3eca8206f 100644
--- a/spec/controllers/sys_controller_spec.rb
+++ b/spec/controllers/sys_controller_spec.rb
@@ -38,9 +38,10 @@ module OpenProjectRepositoryAuthenticationSpecs
let(:guest_role) { FactoryGirl.create(:role, permissions: []) }
let(:valid_user_password) { 'Top Secret Password' }
let(:valid_user) {
- FactoryGirl.create(:user, login: 'johndoe',
- password: valid_user_password,
- password_confirmation: valid_user_password)
+ FactoryGirl.create(:user,
+ login: 'johndoe',
+ password: valid_user_password,
+ password_confirmation: valid_user_password)
}
before(:each) do
@@ -48,9 +49,10 @@ module OpenProjectRepositoryAuthenticationSpecs
DeletedUser.first # creating it first in order to avoid problems with should_receive
random_project = FactoryGirl.create(:project, is_public: false)
- @member = FactoryGirl.create(:member, user: valid_user,
- roles: [browse_role],
- project: random_project)
+ @member = FactoryGirl.create(:member,
+ user: valid_user,
+ roles: [browse_role],
+ project: random_project)
allow(Setting).to receive(:sys_api_key).and_return('12345678')
allow(Setting).to receive(:sys_api_enabled?).and_return(true)
allow(Setting).to receive(:repository_authentication_caching_enabled?).and_return(true)
@@ -60,7 +62,7 @@ module OpenProjectRepositoryAuthenticationSpecs
before(:each) do
@key = Setting.sys_api_key
request.env['HTTP_AUTHORIZATION'] = ActionController::HttpAuthentication::Basic.encode_credentials(valid_user.login, valid_user_password)
- post 'repo_auth', key: @key, repository: 'without-access', method: 'GET'
+ post 'repo_auth', key: @key, repository: 'without-access', method: 'GET'
end
it 'should respond 403 not allowed' do
@@ -73,19 +75,20 @@ module OpenProjectRepositoryAuthenticationSpecs
before(:each) do
@key = Setting.sys_api_key
@project = FactoryGirl.create(:project, is_public: false)
- @member = FactoryGirl.create(:member, user: valid_user,
- roles: [browse_role],
- project: @project)
+ @member = FactoryGirl.create(:member,
+ user: valid_user,
+ roles: [browse_role],
+ project: @project)
request.env['HTTP_AUTHORIZATION'] = ActionController::HttpAuthentication::Basic.encode_credentials(valid_user.login, valid_user_password)
end
it 'should respond 200 okay dokay for GET' do
- post 'repo_auth', key: @key, repository: @project.identifier, method: 'GET'
+ post 'repo_auth', key: @key, repository: @project.identifier, method: 'GET'
expect(response.code).to eq('200')
end
it 'should respond 403 not allowed for POST' do
- post 'repo_auth', key: @key, repository: @project.identifier, method: 'POST'
+ post 'repo_auth', key: @key, repository: @project.identifier, method: 'POST'
expect(response.code).to eq('403')
end
end
@@ -94,20 +97,21 @@ module OpenProjectRepositoryAuthenticationSpecs
before(:each) do
@key = Setting.sys_api_key
@project = FactoryGirl.create(:project, is_public: false)
- @member = FactoryGirl.create(:member, user: valid_user,
- roles: [commit_role],
- project: @project)
+ @member = FactoryGirl.create(:member,
+ user: valid_user,
+ roles: [commit_role],
+ project: @project)
valid_user.save
request.env['HTTP_AUTHORIZATION'] = ActionController::HttpAuthentication::Basic.encode_credentials(valid_user.login, valid_user_password)
end
it 'should respond 200 okay dokay for GET' do
- post 'repo_auth', key: @key, repository: @project.identifier, method: 'GET'
+ post 'repo_auth', key: @key, repository: @project.identifier, method: 'GET'
expect(response.code).to eq('200')
end
it 'should respond 200 okay dokay for POST' do
- post 'repo_auth', key: @key, repository: @project.identifier, method: 'POST'
+ post 'repo_auth', key: @key, repository: @project.identifier, method: 'POST'
expect(response.code).to eq('200')
end
end
@@ -116,11 +120,12 @@ module OpenProjectRepositoryAuthenticationSpecs
before(:each) do
@key = Setting.sys_api_key
@project = FactoryGirl.create(:project, is_public: false)
- @member = FactoryGirl.create(:member, user: valid_user,
- roles: [commit_role],
- project: @project)
+ @member = FactoryGirl.create(:member,
+ user: valid_user,
+ roles: [commit_role],
+ project: @project)
request.env['HTTP_AUTHORIZATION'] = ActionController::HttpAuthentication::Basic.encode_credentials(valid_user.login, valid_user_password + 'made invalid')
- post 'repo_auth', key: @key, repository: @project.identifier, method: 'GET'
+ post 'repo_auth', key: @key, repository: @project.identifier, method: 'GET'
end
it 'should respond 401 auth required' do
@@ -133,7 +138,7 @@ module OpenProjectRepositoryAuthenticationSpecs
@key = Setting.sys_api_key
@project = FactoryGirl.create(:project, is_public: false)
request.env['HTTP_AUTHORIZATION'] = ActionController::HttpAuthentication::Basic.encode_credentials(valid_user.login, valid_user_password)
- post 'repo_auth', key: @key, repository: @project.identifier, method: 'GET'
+ post 'repo_auth', key: @key, repository: @project.identifier, method: 'GET'
end
it 'should respond 403 not allowed' do
@@ -147,12 +152,13 @@ module OpenProjectRepositoryAuthenticationSpecs
@project = FactoryGirl.create(:project, is_public: true)
random_project = FactoryGirl.create(:project, is_public: false)
- @member = FactoryGirl.create(:member, user: valid_user,
- roles: [browse_role],
- project: random_project)
+ @member = FactoryGirl.create(:member,
+ user: valid_user,
+ roles: [browse_role],
+ project: random_project)
request.env['HTTP_AUTHORIZATION'] = ActionController::HttpAuthentication::Basic.encode_credentials(valid_user.login, valid_user_password)
- post 'repo_auth', key: @key, repository: @project.identifier, method: 'GET'
+ post 'repo_auth', key: @key, repository: @project.identifier, method: 'GET'
end
it 'should respond 200 OK' do
@@ -163,7 +169,7 @@ module OpenProjectRepositoryAuthenticationSpecs
describe '#repo_auth', 'for invalid credentials' do
before(:each) do
@key = Setting.sys_api_key
- post 'repo_auth', key: @key, repository: 'any-repo', method: 'GET'
+ post 'repo_auth', key: @key, repository: 'any-repo', method: 'GET'
end
it 'should respond 401 auth required' do
@@ -179,14 +185,14 @@ module OpenProjectRepositoryAuthenticationSpecs
it 'should respond 403 for valid username/password' do
request.env['HTTP_AUTHORIZATION'] = ActionController::HttpAuthentication::Basic.encode_credentials(valid_user.login, valid_user_password)
- post 'repo_auth', key: @key, repository: 'any-repo', method: 'GET'
+ post 'repo_auth', key: @key, repository: 'any-repo', method: 'GET'
expect(response.code).to eq('403')
expect(response.body).to eq('Access denied. Repository management WS is disabled or key is invalid.')
end
it 'should respond 403 for invalid username/password' do
request.env['HTTP_AUTHORIZATION'] = ActionController::HttpAuthentication::Basic.encode_credentials('invalid', 'invalid')
- post 'repo_auth', key: @key, repository: 'any-repo', method: 'GET'
+ post 'repo_auth', key: @key, repository: 'any-repo', method: 'GET'
expect(response.code).to eq('403')
expect(response.body).to eq('Access denied. Repository management WS is disabled or key is invalid.')
end
@@ -210,15 +216,13 @@ module OpenProjectRepositoryAuthenticationSpecs
end
it 'should return the same as user_login for valid creds' do
- expect(controller.send(:cached_user_login, valid_user.login, valid_user_password)).to eq(
- controller.send(:user_login, valid_user.login, valid_user_password)
- )
+ expect(controller.send(:cached_user_login, valid_user.login, valid_user_password))
+ .to eq(controller.send(:user_login, valid_user.login, valid_user_password))
end
it 'should return the same as user_login for invalid creds' do
- expect(controller.send(:cached_user_login, 'invalid', 'invalid')).to eq(
- controller.send(:user_login, 'invalid', 'invalid')
- )
+ expect(controller.send(:cached_user_login, 'invalid', 'invalid'))
+ .to eq(controller.send(:user_login, 'invalid', 'invalid'))
end
it 'should use cache' do
@@ -261,5 +265,142 @@ module OpenProjectRepositoryAuthenticationSpecs
end
end
end
+
+ describe 'update_required_storage' do
+ let(:force) { nil }
+ let(:apikey) { Setting.sys_api_key }
+ let(:last_updated) { nil }
+
+ def request_storage
+ get 'update_required_storage', key: apikey, id: id, force: force
+ end
+
+ context 'missing project' do
+ let(:id) { 1234 }
+
+ it 'returns 404' do
+ request_storage
+ expect(response.code).to eq('404')
+ expect(response.body).to include('Could not find project #1234')
+ end
+ end
+
+ context 'available project, but missing repository' do
+ let(:project) { FactoryGirl.build_stubbed(:project) }
+ let(:id) { project.id }
+ before do
+ allow(Project).to receive(:find).and_return(project)
+ request_storage
+ end
+
+ it 'returns 404' do
+ expect(response.code).to eq('404')
+ expect(response.body).to include("Project ##{project.id} does not have a repository.")
+ end
+ end
+
+ context 'stubbed repository' do
+ let(:project) { FactoryGirl.build_stubbed(:project) }
+ let(:id) { project.id }
+ let(:repository) {
+ FactoryGirl.build_stubbed(:repository_subversion, url: url, root_url: url)
+ }
+
+ before do
+ allow(Project).to receive(:find).and_return(project)
+ allow(project).to receive(:repository).and_return(repository)
+
+ allow(repository).to receive(:storage_updated_at).and_return(last_updated)
+ request_storage
+ end
+
+ context 'local non-existing repository' do
+ let(:root_url) { '/tmp/does/not/exist/svn/foo.svn' }
+ let(:url) { "file://#{root_url}" }
+
+ it 'does not have storage available' do
+ expect(repository.scm.storage_available?).to be false
+ expect(response.code).to eq('400')
+ end
+ end
+
+ context 'remote stubbed repository' do
+ let(:root_url) { '' }
+ let(:url) { 'https://foo.example.org/svn/bar' }
+
+ it 'has no storage available' do
+ request_storage
+ expect(repository.scm.storage_available?).to be false
+ expect(response.code).to eq('400')
+ end
+ end
+ end
+
+ context 'local existing repository' do
+ with_subversion_repository do |repo_dir|
+ let(:root_url) { repo_dir }
+ let(:url) { "file://#{root_url}" }
+
+ let(:project) { FactoryGirl.create(:project) }
+ let(:id) { project.id }
+ let(:repository) {
+ FactoryGirl.create(:repository_subversion, project: project, url: url, root_url: url)
+ }
+
+ before do
+ allow(Project).to receive(:find).and_return(project)
+ allow(project).to receive(:repository).and_return(repository)
+ allow(repository).to receive(:storage_updated_at).and_return(last_updated)
+ end
+
+ it 'has storage available' do
+ expect(repository.scm.storage_available?).to be true
+ end
+
+ context 'storage never updated before' do
+ it 'updates the storage' do
+ expect(repository.required_storage_bytes).to be == 0
+ request_storage
+
+ repository.reload
+ expect(repository.required_storage_bytes).to be > 0
+ end
+ end
+
+ context 'outdated storage' do
+ let(:last_updated) { 2.days.ago }
+ it 'updates the storage' do
+ expect(Delayed::Job)
+ .to receive(:enqueue).with(instance_of(::Scm::StorageUpdaterJob))
+
+ request_storage
+ end
+ end
+
+ context 'valid storage time' do
+ let(:last_updated) { 10.minutes.ago }
+
+ it 'does not update to storage' do
+ expect(Delayed::Job)
+ .not_to receive(:enqueue).with(instance_of(::Scm::StorageUpdaterJob))
+
+ request_storage
+ end
+ end
+
+ context 'valid storage time and force' do
+ let(:force) { '1' }
+ let(:last_updated) { 10.minutes.ago }
+
+ it 'does update to storage' do
+ expect(Delayed::Job)
+ .to receive(:enqueue).with(instance_of(::Scm::StorageUpdaterJob))
+
+ request_storage
+ end
+ end
+ end
+ end
+ end
end
end
diff --git a/spec/controllers/work_packages/creation_spec.rb b/spec/controllers/work_packages/creation_spec.rb
index cf1bbc6db08..18b7b9ef7c1 100644
--- a/spec/controllers/work_packages/creation_spec.rb
+++ b/spec/controllers/work_packages/creation_spec.rb
@@ -73,7 +73,7 @@ describe WorkPackagesController, type: :controller do
# Find the enqueued job responsible for sending the notification
# for the creation of the work package.
Delayed::Job.all.map(&:payload_object).detect do |job|
- if job.is_a? DeliverWorkPackageCreatedJob
+ if job.is_a? EnqueueWorkPackageNotificationJob
job.send(:work_package) == work_package
end
end
@@ -124,10 +124,6 @@ describe WorkPackagesController, type: :controller do
it 'is enqueued' do
expect(job).to be_present
end
-
- it 'fails' do
- expect { job.perform }.to raise_error(SocketError)
- end
end
end
end
diff --git a/spec/controllers/work_packages_controller_spec.rb b/spec/controllers/work_packages_controller_spec.rb
index f6419183498..c41c30920f6 100644
--- a/spec/controllers/work_packages_controller_spec.rb
+++ b/spec/controllers/work_packages_controller_spec.rb
@@ -938,7 +938,7 @@ describe WorkPackagesController, type: :controller do
describe '#journal' do
let(:attachment_id) { "attachments_#{new_work_package.attachments.first.id}" }
- subject { new_work_package.journals.last.changed_data }
+ subject { new_work_package.journals.last.details }
it { is_expected.to have_key attachment_id }
diff --git a/spec/exemplars/repository_exemplar.rb b/spec/exemplars/repository_exemplar.rb
index c26d83043a4..23bdc404ab8 100644
--- a/spec/exemplars/repository_exemplar.rb
+++ b/spec/exemplars/repository_exemplar.rb
@@ -30,6 +30,7 @@
class Repository < ActiveRecord::Base
generator_for type: 'Repository::Subversion'
generator_for :url, method: :next_url
+ generator_for :scm_type, 'local'
def self.next_url
@last_url ||= 'file:///test/svn'
diff --git a/spec/factories/project_factory.rb b/spec/factories/project_factory.rb
index ac97a3fdc6b..68e80b911b0 100644
--- a/spec/factories/project_factory.rb
+++ b/spec/factories/project_factory.rb
@@ -72,123 +72,3 @@ FactoryGirl.define do
end
end
end
-
-FactoryGirl.define do
- factory(:timelines_project, class: Project) do
- sequence(:name) do |n| "Project #{n}" end
- sequence(:identifier) do |n| "project#{n}" end
-
- # activate timeline module
-
- callback(:after_create) do |project|
- project.enabled_module_names += ['timelines']
- end
-
- # add user to project
-
- callback(:after_create) do |project|
- role = FactoryGirl.create(:role)
- member = FactoryGirl.build(:member,
- # we could also just make everybody a member,
- # since for now we can't pass transient
- # attributes into factory_girl
- user: project.responsible,
- project: project)
- member.roles = [role]
- member.save!
- end
-
- # generate planning elements
-
- callback(:after_create) do |project|
- start_date = rand(18.months).ago
- due_date = start_date
-
- (5 + rand(20)).times do
- due_date = start_date + (rand(30) + 10).days
- FactoryGirl.create(:planning_element, project: project,
- start_date: start_date,
- due_date: due_date)
- start_date = due_date
- end
- end
-
- # create a timeline in that project
-
- callback(:after_create) do |project|
- FactoryGirl.create(:timeline, project: project)
- end
- end
-end
-
-FactoryGirl.define do
- factory(:uerm_project, parent: :project) do
- sequence(:name) do |n| "ÜRM Project #{n}" end
-
- @project_types = Array.new
- @planning_element_types = Array.new
- @colors = PlanningElementTypeColor.colors
-
- # create some project types
-
- callback(:after_create) do |_project|
- if @project_types.empty?
-
- 6.times do
- @project_types << FactoryGirl.create(:project_type)
- end
-
- end
- end
-
- # create some planning_element_types
-
- callback(:after_create) do |_project|
- 20.times do
- planning_element_type = FactoryGirl.create(:planning_element_type)
- planning_element_type.color = @colors.sample
- planning_element_type.save
-
- @planning_element_types << planning_element_type
- end
- end
-
- callback(:after_create) do |project|
- projects = Array.new
-
- # create some projects
- #
- 50.times do
- projects << FactoryGirl.create(:project,
- responsible: project.responsible)
- end
-
- projects << FactoryGirl.create(:project,
- responsible: project.responsible)
-
- projects.each do |r|
- # give every project a project type
-
- r.project_type = @project_types.sample
- r.save
-
- # create a reporting to ürm
-
- FactoryGirl.create(:reporting,
- project: r,
- reporting_to_project: project)
-
- # give every planning element a planning element type
-
- r.planning_elements.each do |pe|
- pe.planning_element_type = @planning_element_types.sample
- pe.save!
- end
-
- # Add a timeline with history
-
- FactoryGirl.create(:timeline_with_history, project: r)
- end
- end
- end
-end
diff --git a/spec/factories/repository_factory.rb b/spec/factories/repository_factory.rb
index bb6e7898846..c93403afdcf 100644
--- a/spec/factories/repository_factory.rb
+++ b/spec/factories/repository_factory.rb
@@ -27,9 +27,16 @@
#++
FactoryGirl.define do
- factory :repository, class: Repository::Filesystem do
- # Setting.enabled_scm should include "Filesystem" to successfully save the created repository
- url 'file:///tmp/test_repo'
+ factory :repository_subversion, class: Repository::Subversion do
+ url 'file://tmp/svn_test_repo'
+ scm_type 'existing'
+ project
+ end
+
+ factory :repository_git, class: Repository::Git do
+ url 'file://tmp/git_test_repo'
+ scm_type 'local'
+ path_encoding 'UTF-8'
project
end
end
diff --git a/spec/features/accessibility/work_packages/calendar_toggable_fieldsets_spec.rb b/spec/features/accessibility/work_packages/calendar_toggable_fieldsets_spec.rb
index 24195a06b0b..17d268cde8d 100644
--- a/spec/features/accessibility/work_packages/calendar_toggable_fieldsets_spec.rb
+++ b/spec/features/accessibility/work_packages/calendar_toggable_fieldsets_spec.rb
@@ -45,7 +45,7 @@ describe 'Work package calendar index', type: :feature do
end
describe 'Filter fieldset', js: true do
- it_behaves_like 'toggable fieldset initially expanded' do
+ it_behaves_like 'toggable fieldset initially collapsed' do
let(:fieldset_name) { 'Filters' }
end
end
diff --git a/spec/features/auth/omniauth_spec.rb b/spec/features/auth/omniauth_spec.rb
index 23598bd0e5a..47a69e34034 100644
--- a/spec/features/auth/omniauth_spec.rb
+++ b/spec/features/auth/omniauth_spec.rb
@@ -167,6 +167,7 @@ describe 'Omniauth authentication', type: :feature do
before do
allow(Setting).to receive(:self_registration?).and_return(true)
allow(Setting).to receive(:self_registration).and_return('3')
+ allow(Setting).to receive(:available_languages).and_return([:en])
end
it_behaves_like 'omniauth user registration'
diff --git a/spec/features/boards/message_spec.rb b/spec/features/boards/message_spec.rb
index a47be630b38..05aa3e330fa 100644
--- a/spec/features/boards/message_spec.rb
+++ b/spec/features/boards/message_spec.rb
@@ -47,9 +47,7 @@ describe 'messages', type: :feature do
end
before do
- visit project_path(topic.board.project)
- click_on 'Forums'
- click_on topic.subject, match: :first
+ visit topic_path(topic)
end
describe 'clicking on quote', js: true do
diff --git a/spec/features/repositories/create_repository_spec.rb b/spec/features/repositories/create_repository_spec.rb
new file mode 100644
index 00000000000..f97dc701633
--- /dev/null
+++ b/spec/features/repositories/create_repository_spec.rb
@@ -0,0 +1,205 @@
+#-- copyright
+# OpenProject is a project management system.
+# Copyright (C) 2012-2015 the OpenProject Foundation (OPF)
+#
+# This program is free software; you can redistribute it and/or
+# modify it under the terms of the GNU General Public License version 3.
+#
+# OpenProject is a fork of ChiliProject, which is a fork of Redmine. The copyright follows:
+# Copyright (C) 2006-2013 Jean-Philippe Lang
+# Copyright (C) 2010-2013 the ChiliProject Team
+#
+# This program is free software; you can redistribute it and/or
+# modify it under the terms of the GNU General Public License
+# as published by the Free Software Foundation; either version 2
+# of the License, or (at your option) any later version.
+#
+# This program is distributed in the hope that it will be useful,
+# but WITHOUT ANY WARRANTY; without even the implied warranty of
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+# GNU General Public License for more details.
+#
+# You should have received a copy of the GNU General Public License
+# along with this program; if not, write to the Free Software
+# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
+#
+# See doc/COPYRIGHT.rdoc for more details.
+#++
+
+require 'spec_helper'
+require 'features/repositories/repository_settings_page'
+
+describe 'Create repository', type: :feature, js: true do
+ let(:current_user) { FactoryGirl.create (:admin) }
+ let(:project) { FactoryGirl.create(:project) }
+ let(:settings_page) { RepositorySettingsPage.new(project) }
+
+ # Allow to override configuration values to determine
+ # whether to activate managed repositories
+ let(:enabled_scms) { %w[Subversion Git] }
+ let(:config) { nil }
+
+ let(:scm_vendor_input_css) { 'select[name="scm_vendor"]' }
+ let(:scm_vendor_input) { find(scm_vendor_input_css) }
+
+ before do
+ allow(User).to receive(:current).and_return current_user
+ allow(Setting).to receive(:enabled_scm).and_return(enabled_scms)
+
+ allow(OpenProject::Configuration).to receive(:[]).and_call_original
+ allow(OpenProject::Configuration).to receive(:[]).with('scm').and_return(config)
+ end
+
+ describe 'vendor select' do
+ before do
+ settings_page.visit_repository_settings
+ end
+ shared_examples 'shows enabled scms' do
+ it 'displays the vendor selection' do
+ expect(scm_vendor_input).not_to be_nil
+ enabled_scms.each do |scm|
+ expect(scm_vendor_input).to have_selector('option', text: scm)
+ end
+ end
+ end
+
+ context 'with the default enabled scms' do
+ it_behaves_like 'shows enabled scms'
+ end
+
+ context 'with only one enabled scm' do
+ let(:enabled_scms) { %w[Subversion] }
+ it_behaves_like 'shows enabled scms'
+ it 'does not show git' do
+ expect(scm_vendor_input).not_to have_selector('option', text: 'Git')
+ end
+ end
+ end
+
+ describe 'with submitted vendor form' do
+ before do
+ settings_page.visit_repository_settings
+ find("option[value='#{vendor}']").select_option
+ end
+
+ shared_examples 'displays only the type' do |type|
+ it 'should display one type, but expanded' do
+ # There seems to be an issue with how the
+ # select is accessed after the async form loading
+ # Thus we explitly find it here to allow some wait
+ # even though it is available in let
+ scm_vendor = find(scm_vendor_input_css)
+ expect(scm_vendor.value).to eq(vendor)
+
+ page.assert_selector('input[name="scm_type"]', count: 1)
+ scm_type = find('input[name="scm_type"]')
+
+ expect(scm_type.value).to eq(type)
+ expect(scm_type[:selected]).to be_truthy
+ expect(scm_type[:disabled]).to be_falsey
+
+ content = find("#toggleable-attributes-group--content-#{type}")
+ expect(content).not_to be_nil
+ expect(content[:hidden]).to be_falsey
+ end
+ end
+
+ shared_examples 'displays collapsed type' do |type|
+ let(:selector) { find("input[name='scm_type'][value='#{type}']") }
+
+ it 'should display a collapsed type' do
+ expect(selector).not_to be_nil
+ expect(selector[:selected]).to be_falsey
+ expect(selector[:disabled]).to be_falsey
+
+ content = find("#toggleable-attributes-group--content-#{type}", visible: false)
+ expect(content).not_to be_nil
+ expect(content[:hidden]).to be_truthy
+ end
+ end
+
+ shared_examples 'has managed and other type' do |type|
+ it_behaves_like 'displays collapsed type', type
+ it_behaves_like 'displays collapsed type', 'managed'
+
+ it 'can toggle between the two' do
+ find("input[name='scm_type'][value='#{type}']").set(true)
+ content = find("#toggleable-attributes-group--content-#{type}")
+ expect(content).not_to be_nil
+ expect(content[:hidden]).to be_falsey
+
+ find('input[type="radio"][value="managed"]').set(true)
+ content = find('#toggleable-attributes-group--content-managed')
+ expect(content).not_to be_nil
+ expect(content[:hidden]).to be_falsey
+ end
+ end
+
+ shared_examples 'it can create the managed repository' do
+ it 'can complete the form without any parameters' do
+ find('input[type="radio"][value="managed"]').set(true)
+ find('button[type="submit"]', text: I18n.t(:button_create)).click
+
+ expect(find('div.flash.notice')).not_to be_nil
+ expect(page).to have_selector('input[name="scm_type"][value="managed"]:checked')
+ expect(page).to have_selector('a.icon-delete', text: I18n.t(:button_delete))
+ end
+ end
+
+ shared_examples 'it can create the repository of type with url' do |type, url|
+ it 'can complete the form without any parameters' do
+ find("input[type='radio'][value='#{type}']").set(true)
+ find('input[name="repository[url]"]').set(url)
+
+ find('button[type="submit"]', text: I18n.t(:button_create)).click
+
+ expect(page).to have_selector('div.flash.notice')
+ expect(page).to have_selector('button[type="submit"]', text: I18n.t(:button_save))
+ expect(page).to have_selector('a.icon-delete', text: I18n.t(:button_delete))
+ end
+ end
+
+ context 'with Subversion selected' do
+ let(:vendor) { 'Subversion' }
+
+ it_behaves_like 'displays only the type', 'existing'
+
+ context 'and managed repositories' do
+ include_context 'with tmpdir'
+ let(:config) {
+ { Subversion: { manages: tmpdir } }
+ }
+ it_behaves_like 'has managed and other type', 'existing'
+ it_behaves_like 'it can create the managed repository'
+ it_behaves_like 'it can create the repository of type with url',
+ 'existing',
+ 'file:///tmp/svn/foo.svn'
+ end
+ end
+
+ context 'with Git selected' do
+ let(:vendor) { 'Git' }
+
+ it_behaves_like 'displays only the type', 'local'
+ context 'and managed repositories, but not ours' do
+ let(:config) {
+ { Subversion: { manages: '/tmp/whatever' } }
+ }
+ it_behaves_like 'displays only the type', 'local'
+ end
+
+ context 'and managed repositories' do
+ include_context 'with tmpdir'
+ let(:config) {
+ { Git: { manages: tmpdir } }
+ }
+
+ it_behaves_like 'has managed and other type', 'local'
+ it_behaves_like 'it can create the managed repository'
+ it_behaves_like 'it can create the repository of type with url',
+ 'local',
+ '/tmp/git/foo.git'
+ end
+ end
+ end
+end
diff --git a/spec/features/repositories/repository_settings_page.rb b/spec/features/repositories/repository_settings_page.rb
new file mode 100644
index 00000000000..2133483af09
--- /dev/null
+++ b/spec/features/repositories/repository_settings_page.rb
@@ -0,0 +1,44 @@
+#-- copyright
+# OpenProject is a project management system.
+# Copyright (C) 2012-2015 the OpenProject Foundation (OPF)
+#
+# This program is free software; you can redistribute it and/or
+# modify it under the terms of the GNU General Public License version 3.
+#
+# OpenProject is a fork of ChiliProject, which is a fork of Redmine. The copyright follows:
+# Copyright (C) 2006-2013 Jean-Philippe Lang
+# Copyright (C) 2010-2013 the ChiliProject Team
+#
+# This program is free software; you can redistribute it and/or
+# modify it under the terms of the GNU General Public License
+# as published by the Free Software Foundation; either version 2
+# of the License, or (at your option) any later version.
+#
+# This program is distributed in the hope that it will be useful,
+# but WITHOUT ANY WARRANTY; without even the implied warranty of
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+# GNU General Public License for more details.
+#
+# You should have received a copy of the GNU General Public License
+# along with this program; if not, write to the Free Software
+# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
+#
+# See doc/COPYRIGHT.rdoc for more details.
+#++
+
+class RepositorySettingsPage
+ include Rails.application.routes.url_helpers
+ include Capybara::DSL
+
+ def initialize(project)
+ @project = project
+ end
+
+ def repository_settings_path
+ settings_project_path(id: @project.id, tab: 'repository')
+ end
+
+ def visit_repository_settings
+ visit repository_settings_path
+ end
+end
diff --git a/spec/features/repositories/repository_settings_spec.rb b/spec/features/repositories/repository_settings_spec.rb
new file mode 100644
index 00000000000..6de538dc2a6
--- /dev/null
+++ b/spec/features/repositories/repository_settings_spec.rb
@@ -0,0 +1,145 @@
+#-- copyright
+# OpenProject is a project management system.
+# Copyright (C) 2012-2015 the OpenProject Foundation (OPF)
+#
+# This program is free software; you can redistribute it and/or
+# modify it under the terms of the GNU General Public License version 3.
+#
+# OpenProject is a fork of ChiliProject, which is a fork of Redmine. The copyright follows:
+# Copyright (C) 2006-2013 Jean-Philippe Lang
+# Copyright (C) 2010-2013 the ChiliProject Team
+#
+# This program is free software; you can redistribute it and/or
+# modify it under the terms of the GNU General Public License
+# as published by the Free Software Foundation; either version 2
+# of the License, or (at your option) any later version.
+#
+# This program is distributed in the hope that it will be useful,
+# but WITHOUT ANY WARRANTY; without even the implied warranty of
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+# GNU General Public License for more details.
+#
+# You should have received a copy of the GNU General Public License
+# along with this program; if not, write to the Free Software
+# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
+#
+# See doc/COPYRIGHT.rdoc for more details.
+#++
+
+require 'spec_helper'
+require 'features/repositories/repository_settings_page'
+
+describe 'Repository Settings', type: :feature, js: true do
+ let(:current_user) { FactoryGirl.create (:admin) }
+ let(:project) { FactoryGirl.create(:project) }
+ let(:settings_page) { RepositorySettingsPage.new(project) }
+
+ # Allow to override configuration values to determine
+ # whether to activate managed repositories
+ let(:enabled_scms) { %w[Subversion Git] }
+ let(:config) { nil }
+
+ before do
+ allow(User).to receive(:current).and_return current_user
+ allow(Setting).to receive(:enabled_scm).and_return(enabled_scms)
+
+ allow(OpenProject::Configuration).to receive(:[]).and_call_original
+ allow(OpenProject::Configuration).to receive(:[]).with('scm').and_return(config)
+
+ allow(project).to receive(:repository).and_return(repository)
+ settings_page.visit_repository_settings
+ end
+
+ shared_examples 'manages the repository' do |type|
+ it 'displays the repository' do
+ expect(page).not_to have_selector('select[name="scm_vendor"]')
+ expect(find("#toggleable-attributes-group--content-#{type}", visible: true))
+ .not_to be_nil
+ end
+
+ it 'deletes the repository' do
+ expect(Repository.exists?(repository)).to be true
+ find('a.icon-delete', text: I18n.t(:button_delete)).click
+
+ # Confirm the notification warning
+ warning = (type == 'managed') ? '-warning.-severe' : '-warning'
+ expect(page).to have_selector(".notification-box.#{warning}")
+ find('a', text: I18n.t(:button_delete)).click
+
+ vendor = find('select[name="scm_vendor"]')
+ expect(vendor).not_to be_nil
+ expect(vendor.value).to be_empty
+
+ selected = vendor.find('option[selected]')
+ expect(selected.text).to eq('--- Please select ---')
+ expect(selected[:disabled]).to be_truthy
+ expect(selected[:selected]).to be_truthy
+
+ # Project should have no repository
+ expect(Repository.exists?(repository)).to be false
+ end
+ end
+
+ shared_examples 'manages the repository with' do |name, type|
+ let(:repository) {
+ FactoryGirl.create("repository_#{name.downcase}".to_sym,
+ scm_type: type,
+ project: project)
+ }
+ it_behaves_like 'manages the repository', type
+ end
+
+ it_behaves_like 'manages the repository with', 'Subversion', 'existing'
+ it_behaves_like 'manages the repository with', 'Git', 'local'
+
+ context 'managed repositories' do
+ include_context 'with tmpdir'
+ let(:config) {
+ {
+ Subversion: { manages: File.join(tmpdir, 'svn') },
+ Git: { manages: File.join(tmpdir, 'git') }
+ }
+ }
+
+ let(:repository) {
+ repo = Repository.build(
+ project,
+ managed_vendor,
+ # Need to pass AC params here manually to simulate a regular repository build
+ ActionController::Parameters.new({}),
+ :managed
+ )
+
+ repo.save!
+ repo
+ }
+
+ context 'Subversion' do
+ let(:managed_vendor) { 'Subversion' }
+ it_behaves_like 'manages the repository', 'managed'
+ end
+
+ context 'Git' do
+ let(:managed_vendor) { 'Git' }
+ it_behaves_like 'manages the repository', 'managed'
+ end
+ end
+
+ describe 'update repositories' do
+ let(:repository) {
+ FactoryGirl.create(:repository_subversion,
+ scm_type: :existing,
+ project: project)
+ }
+
+ it 'can set login and password' do
+ fill_in('repository[login]', with: 'foobar')
+ fill_in('repository_password', with: 'password')
+
+ click_button(I18n.t(:button_save))
+ expect(page).to have_selector('[name="repository[login]"][value="foobar"]')
+ expect(page).to have_selector('.flash',
+ text: I18n.t('repositories.update_settings_successful'))
+ end
+ end
+end
diff --git a/spec/features/work_packages/details/activity_comments_spec.rb b/spec/features/work_packages/details/activity_comments_spec.rb
index 9ace797ba5b..e54b6478527 100644
--- a/spec/features/work_packages/details/activity_comments_spec.rb
+++ b/spec/features/work_packages/details/activity_comments_spec.rb
@@ -18,20 +18,26 @@ describe 'activity comments', js: true do
row = page.find("#work-package-#{work_package.id}")
row.double_click
- expect(find('#add-comment-text')).to be_present
+ ng_wait
end
it 'should alert user if navigating with unsaved form' do
- page.execute_script("jQuery('#add-comment-text').val('Foobar').trigger('change')")
+ fill_in I18n.t('js.label_add_comment_title'), with: 'Foobar'
+
visit root_path
+
page.driver.browser.switch_to.alert.accept
+
expect(current_path).to eq(root_path)
end
it 'should not alert if comment has been submitted' do
- page.execute_script("jQuery('#add-comment-text').val('Foobar').trigger('change')")
- page.execute_script("jQuery('#add-comment-text').siblings('button').trigger('click')")
+ fill_in I18n.t('js.label_add_comment_title'), with: 'Foobar'
+
+ click_button I18n.t('js.label_add_comment')
+
visit root_path
+
expect(current_path).to eq(root_path)
end
end
diff --git a/spec/features/work_packages/details/inplace_editor/description_editor_spec.rb b/spec/features/work_packages/details/inplace_editor/description_editor_spec.rb
index 81dd67e7894..7c2d03e356d 100644
--- a/spec/features/work_packages/details/inplace_editor/description_editor_spec.rb
+++ b/spec/features/work_packages/details/inplace_editor/description_editor_spec.rb
@@ -25,12 +25,7 @@ describe 'description inplace editor', js: true do
before do
allow(User).to receive(:current).and_return(user)
- work_packages_page.visit_index
-
- row = page.find("#work-package-#{work_package.id}")
- row.double_click
-
- ng_wait
+ work_packages_page.visit_index(work_package)
end
context 'in read state' do
diff --git a/spec/features/work_packages/details/inplace_editor/subject_editor_spec.rb b/spec/features/work_packages/details/inplace_editor/subject_editor_spec.rb
index 0e879189806..fc91ea6a9e5 100644
--- a/spec/features/work_packages/details/inplace_editor/subject_editor_spec.rb
+++ b/spec/features/work_packages/details/inplace_editor/subject_editor_spec.rb
@@ -18,14 +18,7 @@ describe 'subject inplace editor', js: true do
before do
allow(User).to receive(:current).and_return(user)
- work_packages_page.visit_index
-
- ensure_wp_table_loaded
-
- row = page.find("#work-package-#{work_package.id}")
- row.double_click
-
- ng_wait
+ work_packages_page.visit_index(work_package)
end
context 'in read state' do
diff --git a/spec/features/work_packages/details/inplace_editor/work_package_field.rb b/spec/features/work_packages/details/inplace_editor/work_package_field.rb
index 7a0720561da..65c954ce89d 100644
--- a/spec/features/work_packages/details/inplace_editor/work_package_field.rb
+++ b/spec/features/work_packages/details/inplace_editor/work_package_field.rb
@@ -1,11 +1,13 @@
class WorkPackageField
+ include Capybara::DSL
+ include RSpec::Matchers
+
attr_reader :element
def initialize(page, property_name)
@property_name = property_name
- # safeguard to ensure that the details pane has been opened
- page.find('.work-packages--details-content')
+ ensure_page_loaded
@element = page.find(field_selector)
end
@@ -68,4 +70,13 @@ class WorkPackageField
def errors_element
@element.find('.inplace-edit--errors')
end
+
+ def ensure_page_loaded
+ if Capybara.current_driver == Capybara.javascript_driver
+ extend ::Angular::DSL unless singleton_class.included_modules.include?(::Angular::DSL)
+ ng_wait
+
+ expect(page).to have_selector('.work-packages--details-content')
+ end
+ end
end
diff --git a/spec/features/work_packages/new_work_package_spec.rb b/spec/features/work_packages/new_work_package_spec.rb
index 7fc239d23b0..19c1ffe3168 100644
--- a/spec/features/work_packages/new_work_package_spec.rb
+++ b/spec/features/work_packages/new_work_package_spec.rb
@@ -37,7 +37,7 @@ describe 'New work package', type: :feature do
before do allow(User).to receive(:current).and_return(user) end
describe 'Datepicker', js: true do
- shared_examples_for 'first week day set' do |locale: 'de'|
+ shared_examples_for 'first week day set' do |locale: :de|
let(:datepicker_selector) { '#ui-datepicker-div table.ui-datepicker-calendar thead tr th:nth-of-type(2)' }
before do
@@ -48,13 +48,9 @@ describe 'New work package', type: :feature do
# Fill in the date, as a simple click does not seem to trigger the datepicker here
fill_in 'Start date', with: DateTime.now.strftime('%Y-%m-%d')
-
- expect(page).to have_selector(datepicker_selector)
end
- subject { page.find(datepicker_selector).text }
-
- it { expect(subject).to eql(day_acronym) }
+ it { expect(page).to have_selector(datepicker_selector, text: day_acronym) }
end
context 'Monday' do
@@ -84,7 +80,7 @@ describe 'New work package', type: :feature do
let(:day_acronym) { 'Mo' }
end
- it_behaves_like 'first week day set', locale: 'en' do
+ it_behaves_like 'first week day set', locale: :en do
let(:day_of_week) { nil }
let(:day_acronym) { 'Su' }
end
diff --git a/spec/features/work_packages/work_packages_page.rb b/spec/features/work_packages/work_packages_page.rb
index c16306dcb66..7669b32ff4a 100644
--- a/spec/features/work_packages/work_packages_page.rb
+++ b/spec/features/work_packages/work_packages_page.rb
@@ -35,8 +35,8 @@ class WorkPackagesPage
@project = project
end
- def visit_index
- visit index_path
+ def visit_index(work_package = nil)
+ visit index_path(work_package)
ensure_index_page_loaded
end
@@ -77,8 +77,10 @@ class WorkPackagesPage
private
- def index_path
- @project ? project_work_packages_path(@project) : work_packages_path
+ def index_path(work_package = nil)
+ path = @project ? project_work_packages_path(@project) : work_packages_path
+ path += "/#{work_package.id}/overview" if work_package
+ path
end
def query_path(query)
@@ -87,6 +89,9 @@ class WorkPackagesPage
def ensure_index_page_loaded
if Capybara.current_driver == Capybara.javascript_driver
+ extend ::Angular::DSL unless singleton_class.included_modules.include?(::Angular::DSL)
+ ng_wait
+
expect(page).to have_selector('.advanced-filters--filter', visible: false)
end
end
diff --git a/spec/fixtures/repositories.yml b/spec/fixtures/repositories.yml
index 3ee2f67e4e7..93dc91b4071 100644
--- a/spec/fixtures/repositories.yml
+++ b/spec/fixtures/repositories.yml
@@ -35,6 +35,7 @@ repositories_001:
password: ""
login: ""
type: Repository::Subversion
+ scm_type: existing
repositories_002:
project_id: 2
url: svn://localhost/test
@@ -43,3 +44,4 @@ repositories_002:
password: ""
login: ""
type: Repository::Subversion
+ scm_type: existing
diff --git a/spec/fixtures/repositories/filesystem_repository.tar.gz b/spec/fixtures/repositories/filesystem_repository.tar.gz
deleted file mode 100644
index 075d8c72a81..00000000000
Binary files a/spec/fixtures/repositories/filesystem_repository.tar.gz and /dev/null differ
diff --git a/spec/fixtures/repositories/git_repository.tar.gz b/spec/fixtures/repositories/git_repository.tar.gz
index c0a1577fe22..2250dcaa5c6 100644
Binary files a/spec/fixtures/repositories/git_repository.tar.gz and b/spec/fixtures/repositories/git_repository.tar.gz differ
diff --git a/spec/fixtures/repositories/subversion_repository.tar.gz b/spec/fixtures/repositories/subversion_repository.tar.gz
new file mode 100644
index 00000000000..7695b80ffee
Binary files /dev/null and b/spec/fixtures/repositories/subversion_repository.tar.gz differ
diff --git a/spec/legacy/functional/admin_controller_spec.rb b/spec/legacy/functional/admin_controller_spec.rb
index 495ffe0f638..79ee6b63eab 100644
--- a/spec/legacy/functional/admin_controller_spec.rb
+++ b/spec/legacy/functional/admin_controller_spec.rb
@@ -45,13 +45,6 @@ describe AdminController, type: :controller do
attributes: { class: /nodata/ }
end
- it 'should index with no configuration data' do
- delete_configuration_data
- get :projects
- assert_tag tag: 'div',
- attributes: { class: /nodata/ }
- end
-
it 'should projects' do
get :projects
assert_response :success
@@ -71,15 +64,6 @@ describe AdminController, type: :controller do
assert_equal 'OnlineStore', projects.first.name
end
- it 'should load default configuration data' do
- Setting.available_languages = [:de]
- delete_configuration_data
- post :default_configuration, lang: 'de'
- assert_response :redirect
- assert_nil flash[:error]
- assert Status.find_by(name: 'neu')
- end
-
it 'should test email' do
get :test_email
assert_redirected_to '/settings/edit?tab=notifications'
@@ -141,13 +125,4 @@ describe AdminController, type: :controller do
menu.delete :test_admin_menu_plugin_extension
end
end
-
- private
-
- def delete_configuration_data
- Role.delete_all('builtin = 0')
- ::Type.delete_all(is_standard: false)
- Status.delete_all
- Enumeration.delete_all
- end
end
diff --git a/spec/legacy/functional/repositories_controller_spec.rb b/spec/legacy/functional/repositories_controller_spec.rb
index b6a6cee2a64..cfda31a3169 100644
--- a/spec/legacy/functional/repositories_controller_spec.rb
+++ b/spec/legacy/functional/repositories_controller_spec.rb
@@ -32,10 +32,28 @@ require 'repositories_controller'
describe RepositoriesController, type: :controller do
render_views
+ # We load legacy fixtures and repository
+ # but now have to override them with the temporary subversion
+ # repository, as the filesystem repository has been stripped.
fixtures :all
+ before do
+ unless repository_configured?('subversion')
+ skip 'Subversion test repository NOT FOUND. Skipping functional tests !!!'
+ end
+ end
+
+ let(:project) { Project.find(1) }
+ let(:repository) {
+ FactoryGirl.create(:repository_subversion,
+ url: self.class.subversion_repository_url,
+ project: project
+ )
+ }
+
before do
User.current = nil
+ allow(project).to receive(:repository).and_return repository
end
it 'should revisions' do
@@ -56,18 +74,32 @@ describe RepositoriesController, type: :controller do
get :revision, project_id: 1, rev: 1
assert_response :success
assert_template 'revision'
- assert_no_tag tag: 'ul', attributes: { id: 'toolbar-items' },
- descendant: { tag: 'a', attributes: { href: @controller.url_for(only_path: true,
- controller: 'repositories',
- action: 'revision',
- project_id: 'ecookbook',
- rev: '0') } }
- assert_tag tag: 'ul', attributes: { id: 'toolbar-items' },
- descendant: { tag: 'a', attributes: { href: @controller.url_for(only_path: true,
- controller: 'repositories',
- action: 'revision',
- project_id: 'ecookbook',
- rev: '2') } }
+ assert_no_tag tag: 'ul',
+ attributes: { id: 'toolbar-items' },
+ descendant: { tag: 'a',
+ attributes: {
+ href: @controller.url_for(
+ only_path: true,
+ controller: 'repositories',
+ action: 'revision',
+ project_id: 'ecookbook',
+ rev: '0'
+ )
+ }
+ }
+ assert_tag tag: 'ul',
+ attributes: { id: 'toolbar-items' },
+ descendant: { tag: 'a',
+ attributes: {
+ href: @controller.url_for(
+ only_path: true,
+ controller: 'repositories',
+ action: 'revision',
+ project_id: 'ecookbook',
+ rev: '2'
+ )
+ }
+ }
end
it 'should graph commits per month' do
@@ -81,7 +113,7 @@ describe RepositoriesController, type: :controller do
# add a commit with an unknown user
Changeset.create!(
repository: Project.find(1).repository,
- committer: 'foo',
+ committer: 'foo',
committed_on: Time.now,
revision: 100,
comments: 'Committed by foo.'
@@ -91,17 +123,20 @@ describe RepositoriesController, type: :controller do
assert_response :success
assert_template 'committers'
- assert_tag :td, content: 'dlopper',
- sibling: { tag: 'td',
- child: { tag: 'select', attributes: { name: %r{^committers\[\d+\]\[\]$} },
- child: { tag: 'option', content: 'Dave Lopper',
- attributes: { value: '3', selected: 'selected' } } } }
- assert_tag :td, content: 'foo',
- sibling: { tag: 'td',
- child: { tag: 'select', attributes: { name: %r{^committers\[\d+\]\[\]$} } } }
- assert_no_tag :td, content: 'foo',
- sibling: { tag: 'td',
- descendant: { tag: 'option', attributes: { selected: 'selected' } } }
+ assert_tag :td,
+ content: 'foo',
+ sibling: {
+ tag: 'td',
+ child: { tag: 'select',
+ attributes: { name: %r{^committers\[\d+\]\[\]$} }
+ }
+ }
+ assert_no_tag :td,
+ content: 'foo',
+ sibling: {
+ tag: 'td',
+ descendant: { tag: 'option', attributes: { selected: 'selected' } }
+ }
end
it 'should map committers' do
@@ -109,13 +144,14 @@ describe RepositoriesController, type: :controller do
# add a commit with an unknown user
c = Changeset.create!(
repository: Project.find(1).repository,
- committer: 'foo',
+ committer: 'foo',
committed_on: Time.now,
revision: 100,
comments: 'Committed by foo.'
)
- assert_no_difference 'Changeset.where(user_id: 3).count' do
- post :committers, project_id: 1, committers: { '0' => ['foo', '2'], '1' => ['dlopper', '3'] }
+ assert_no_difference "Changeset.count(:conditions => 'user_id = 3')" do
+ post :committers, project_id: 1,
+ committers: { '0' => ['foo', '2'], '1' => ['dlopper', '3'] }
assert_redirected_to '/projects/ecookbook/repository/committers'
assert_equal User.find(2), c.reload.user
end
diff --git a/spec/legacy/functional/repositories_filesystem_controller_spec.rb b/spec/legacy/functional/repositories_filesystem_controller_spec.rb
deleted file mode 100644
index 48b150774c3..00000000000
--- a/spec/legacy/functional/repositories_filesystem_controller_spec.rb
+++ /dev/null
@@ -1,127 +0,0 @@
-#-- encoding: UTF-8
-#-- copyright
-# OpenProject is a project management system.
-# Copyright (C) 2012-2015 the OpenProject Foundation (OPF)
-#
-# This program is free software; you can redistribute it and/or
-# modify it under the terms of the GNU General Public License version 3.
-#
-# OpenProject is a fork of ChiliProject, which is a fork of Redmine. The copyright follows:
-# Copyright (C) 2006-2013 Jean-Philippe Lang
-# Copyright (C) 2010-2013 the ChiliProject Team
-#
-# This program is free software; you can redistribute it and/or
-# modify it under the terms of the GNU General Public License
-# as published by the Free Software Foundation; either version 2
-# of the License, or (at your option) any later version.
-#
-# This program is distributed in the hope that it will be useful,
-# but WITHOUT ANY WARRANTY; without even the implied warranty of
-# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
-# GNU General Public License for more details.
-#
-# You should have received a copy of the GNU General Public License
-# along with this program; if not, write to the Free Software
-# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
-#
-# See doc/COPYRIGHT.rdoc for more details.
-#++
-require 'legacy_spec_helper'
-require 'repositories_controller'
-
-describe RepositoriesController, 'Filesystem', type: :controller do
- render_views
-
- fixtures :all
-
- PRJ_ID = 3
-
- before do
- session[:user_id] = 1 # admin
-
- with_existing_filesystem_scm do |repo_path|
- @repository = Repository::Filesystem.create(project: Project.find(PRJ_ID),
- url: repo_path,
- path_encoding: nil)
- assert @repository
- end
- end
-
- after do
- User.current = nil
- end
-
- it 'should browse root' do
- with_existing_filesystem_scm do
- @repository.fetch_changesets
- @repository.reload
- get :show, project_id: PRJ_ID
- assert_response :success
- assert_template 'show'
- assert_not_nil assigns(:entries)
- assert assigns(:entries).size > 0
- assert_not_nil assigns(:changesets)
- assert assigns(:changesets).size == 0
- end
- end
-
- it 'should show no extension' do
- with_existing_filesystem_scm do
- get :entry, project_id: PRJ_ID, path: 'test'
- assert_response :success
- assert_template 'entry'
- assert_tag tag: 'th',
- content: '1',
- attributes: { class: 'line-num' },
- sibling: { tag: 'td', content: /TEST CAT/ }
- end
- end
-
- it 'should entry download no extension' do
- with_existing_filesystem_scm do |_|
- get :entry, project_id: PRJ_ID, path: 'test', format: 'raw'
- assert_response :success
- assert_equal 'application/octet-stream', response.content_type
- end
- end
-
- it 'should show non ascii contents' do
- with_existing_filesystem_scm do
- with_settings repositories_encodings: 'UTF-8,EUC-JP' do
- get :entry, project_id: PRJ_ID, path: 'japanese/euc-jp.txt'
- assert_response :success
- assert_template 'entry'
- assert_tag tag: 'th',
- content: '2',
- attributes: { class: 'line-num' },
- sibling: { tag: 'td', content: /japanese/ }
- end
- end
- end
-
- it 'should show utf16' do
- with_existing_filesystem_scm do
- with_settings repositories_encodings: 'UTF-16' do
- get :entry, project_id: PRJ_ID, path: 'japanese/utf-16.txt'
- assert_response :success
-
- assert_select 'tr' do
- assert_select 'th.line-num' do
- assert_select 'a', text: /2/
- end
- assert_select 'td', content: /japanese/
- end
- end
- end
- end
-
- it 'should show text file should send if too big' do
- with_existing_filesystem_scm do
- with_settings file_max_size_displayed: 1 do
- get :entry, project_id: PRJ_ID, path: 'japanese/big-file.txt'
- assert_response :success
- assert_equal 'text/plain', response.content_type
- end
- end
- end
-end
diff --git a/spec/legacy/functional/repositories_git_controller_spec.rb b/spec/legacy/functional/repositories_git_controller_spec.rb
index dc13f80b744..689862f7e75 100644
--- a/spec/legacy/functional/repositories_git_controller_spec.rb
+++ b/spec/legacy/functional/repositories_git_controller_spec.rb
@@ -47,6 +47,7 @@ describe RepositoriesController, 'Git', type: :controller do
User.current = nil
@repository = Repository::Git.create(
project: Project.find(3),
+ scm_type: 'local',
url: git_repository_path,
path_encoding: 'ISO-8859-1'
)
@@ -67,7 +68,7 @@ describe RepositoriesController, 'Git', type: :controller do
assert_response :success
assert_template 'show'
assert_not_nil assigns(:entries)
- assert_equal 9, assigns(:entries).size
+ assert_equal 10, assigns(:entries).size
assert assigns(:entries).detect { |e| e.name == 'images' && e.kind == 'dir' }
assert assigns(:entries).detect { |e| e.name == 'this_is_a_really_long_and_verbose_directory_name' && e.kind == 'dir' }
assert assigns(:entries).detect { |e| e.name == 'sources' && e.kind == 'dir' }
@@ -146,7 +147,7 @@ describe RepositoriesController, 'Git', type: :controller do
get :changes, project_id: 3, path: 'images/edit.png'
assert_response :success
assert_template 'changes'
- assert_tag tag: 'h2', content: 'edit.png'
+ assert_tag tag: 'h3', content: 'edit.png'
end
it 'should entry show' do
@@ -223,7 +224,7 @@ describe RepositoriesController, 'Git', type: :controller do
get :annotate, project_id: 3, rev: 'deff7', path: 'sources/watchers_controller.rb'
assert_response :success
assert_template 'annotate'
- assert_tag tag: 'h2', content: /@ deff712f/
+ assert_tag tag: 'h3', content: /@ deff712f/
end
it 'should annotate binary file' do
diff --git a/spec/legacy/functional/repositories_subversion_controller_spec.rb b/spec/legacy/functional/repositories_subversion_controller_spec.rb
index 8db535f4dc4..4223654aa2e 100644
--- a/spec/legacy/functional/repositories_subversion_controller_spec.rb
+++ b/spec/legacy/functional/repositories_subversion_controller_spec.rb
@@ -44,6 +44,7 @@ describe RepositoriesController, 'Subversion', type: :controller do
@project = Project.find(PRJ_ID)
@repository = Repository::Subversion.create(project: @project,
+ scm_type: 'local',
url: self.class.subversion_repository_url)
# #reload is broken for repositories because it defines
@@ -114,7 +115,7 @@ describe RepositoriesController, 'Subversion', type: :controller do
assert_equal %w(6 3 2), changesets.map(&:revision)
# svn properties displayed with svn >= 1.5 only
- if Redmine::Scm::Adapters::SubversionAdapter.client_version_above?([1, 5, 0])
+ if @repository.scm.client_version_above?([1, 5, 0])
assert_not_nil assigns(:properties)
assert_equal 'native', assigns(:properties)['svn:eol-style']
assert_tag :ul,
@@ -243,7 +244,7 @@ describe RepositoriesController, 'Subversion', type: :controller do
it 'should revision with repository pointing to a subdirectory' do
r = Project.find(1).repository
# Changes repository url to a subdirectory
- r.update_attribute :url, (r.url + '/test/some')
+ r.update_attribute :url, (r.url + '/subversion_test/folder/')
get :revision, project_id: 1, rev: 2
assert_response :success
@@ -252,11 +253,11 @@ describe RepositoriesController, 'Subversion', type: :controller do
child: { tag: 'li',
# link to the entry at rev 2
child: { tag: 'a',
- attributes: { href: '/projects/ecookbook/repository/revisions/2/entry/path/in/the/repo' },
+ attributes: { href: '/projects/ecookbook/repository/revisions/2/entry/test/some/path/in/the/repo' },
content: 'repo',
# link to partial diff
sibling: { tag: 'a',
- attributes: { href: '/projects/ecookbook/repository/revisions/2/diff/path/in/the/repo' }
+ attributes: { href: '/projects/ecookbook/repository/revisions/2/diff/test/some/path/in/the/repo' }
}
}
}
@@ -301,6 +302,6 @@ describe RepositoriesController, 'Subversion', type: :controller do
get :annotate, project_id: PRJ_ID, rev: 8, path: 'subversion_test/helloworld.c'
assert_response :success
assert_template 'annotate'
- assert_tag tag: 'h2', content: /@ 8/
+ assert_tag tag: 'h3', content: /@ 8/
end
end
diff --git a/spec/legacy/functional/sys_controller_spec.rb b/spec/legacy/functional/sys_controller_spec.rb
index a927198180b..be4f572ec9b 100644
--- a/spec/legacy/functional/sys_controller_spec.rb
+++ b/spec/legacy/functional/sys_controller_spec.rb
@@ -51,7 +51,8 @@ describe SysController, type: :controller do
assert_nil Project.find(4).repository
post :create_project_repository, id: 4,
- vendor: 'Subversion',
+ scm_vendor: 'Subversion',
+ scm_type: 'existing',
repository: { url: 'file:///create/project/repository/subproject2' }
assert_response :created
diff --git a/spec/legacy/functional/user_mailer_spec.rb b/spec/legacy/functional/user_mailer_spec.rb
index 48c25cf8267..da90c82b152 100644
--- a/spec/legacy/functional/user_mailer_spec.rb
+++ b/spec/legacy/functional/user_mailer_spec.rb
@@ -201,7 +201,7 @@ describe UserMailer, type: :mailer do
it 'should email headers' do
user = FactoryGirl.create(:user)
issue = FactoryGirl.create(:work_package)
- mail = UserMailer.work_package_added(user, issue, user)
+ mail = UserMailer.work_package_added(user, issue.journals.first, user)
assert mail.deliver
assert_not_nil mail
assert_equal 'bulk', mail.header['Precedence'].to_s
@@ -212,7 +212,7 @@ describe UserMailer, type: :mailer do
Setting.plain_text_mail = 1
user = FactoryGirl.create(:user)
issue = FactoryGirl.create(:work_package)
- UserMailer.work_package_added(user, issue, user).deliver
+ UserMailer.work_package_added(user, issue.journals.first, user).deliver
mail = ActionMailer::Base.deliveries.last
assert_match /text\/plain/, mail.content_type
assert_equal 0, mail.parts.size
@@ -223,7 +223,7 @@ describe UserMailer, type: :mailer do
Setting.plain_text_mail = 0
user = FactoryGirl.create(:user)
issue = FactoryGirl.create(:work_package)
- UserMailer.work_package_added(user, issue, user).deliver
+ UserMailer.work_package_added(user, issue.journals.first, user).deliver
mail = ActionMailer::Base.deliveries.last
assert_match /multipart\/alternative/, mail.content_type
assert_equal 2, mail.parts.size
@@ -264,7 +264,7 @@ describe UserMailer, type: :mailer do
it 'should issue add message id' do
user = FactoryGirl.create(:user)
issue = FactoryGirl.create(:work_package)
- mail = UserMailer.work_package_added(user, issue, user)
+ mail = UserMailer.work_package_added(user, issue.journals.first, user)
mail.deliver
assert_not_nil mail
assert_equal UserMailer.generate_message_id(issue, user), mail.message_id
@@ -318,7 +318,7 @@ describe UserMailer, type: :mailer do
ActionMailer::Base.deliveries.clear
with_settings available_languages: ['en', 'de'] do
I18n.locale = 'en'
- assert UserMailer.work_package_added(user, issue, user).deliver
+ assert UserMailer.work_package_added(user, issue.journals.first, user).deliver
assert_equal 1, ActionMailer::Base.deliveries.size
mail = last_email
assert_equal ['foo@bar.de'], mail.to
@@ -338,7 +338,7 @@ describe UserMailer, type: :mailer do
with_settings available_languages: ['en', 'de'],
default_language: 'de' do
I18n.locale = 'de'
- assert UserMailer.work_package_added(user, issue, user).deliver
+ assert UserMailer.work_package_added(user, issue.journals.first, user).deliver
assert_equal 1, ActionMailer::Base.deliveries.size
mail = last_email
assert_equal ['foo@bar.de'], mail.to
@@ -490,17 +490,12 @@ describe UserMailer, type: :mailer do
# now change the issue, to get a nice journal
issue.description = "This is related to issue ##{related_issue.id}\n"
- changeset = with_existing_filesystem_scm { |repo_url|
- repository = FactoryGirl.build(:repository,
- url: repo_url,
+ repository = FactoryGirl.create(:repository_subversion,
project: project)
- repository.save!
-
- FactoryGirl.create :changeset,
+ changeset = FactoryGirl.create :changeset,
repository: repository,
comments: 'This commit fixes #1, #2 and references #1 and #3'
- }
issue.description += " A reference to a changeset r#{changeset.revision}\n" if changeset
diff --git a/spec/legacy/support/legacy_assertions.rb b/spec/legacy/support/legacy_assertions.rb
index 16db4d3fe38..f83928a8be6 100644
--- a/spec/legacy/support/legacy_assertions.rb
+++ b/spec/legacy/support/legacy_assertions.rb
@@ -116,20 +116,6 @@ module LegacyAssertionsAndHelpers
saved_settings.each { |k, v| Setting[k] = v }
end
- REPOSITORY_PATH = Rails.root.to_s.gsub(%r{config\/\.\.}, '') + '/tmp/test/filesystem_repository'
-
- def with_existing_filesystem_scm(&block)
- if Dir.exists?(REPOSITORY_PATH)
- Setting.enabled_scm = Setting.enabled_scm << 'Filesystem' unless Setting.enabled_scm.include? 'Filesystem'
- OpenProject::Configuration['scm_filesystem_path_whitelist'] = [REPOSITORY_PATH]
-
- block.call(REPOSITORY_PATH)
- else
- warn 'Filesystem test repository NOT FOUND. Skipping tests !!! See doc/RUNNING_TESTS.'
- nil
- end
- end
-
def change_user_password(login, new_password)
user = User.find_by_login(login)
user.password = new_password
diff --git a/spec/legacy/unit/changeset_spec.rb b/spec/legacy/unit/changeset_spec.rb
index 3d99225698b..1eb7317f68f 100644
--- a/spec/legacy/unit/changeset_spec.rb
+++ b/spec/legacy/unit/changeset_spec.rb
@@ -187,7 +187,8 @@ describe Changeset, type: :model do
# repository of child project
r = Repository::Subversion.create!(
project: Project.find(3),
- url: 'svn://localhost/test')
+ scm_type: 'existing',
+ url: 'svn://localhost/test')
c = Changeset.new(repository: r,
committed_on: Time.now,
@@ -235,128 +236,31 @@ describe Changeset, type: :model do
assert_nil changeset.next
end
- it 'should comments should be converted to utf8' do
- with_settings enabled_scm: ['Filesystem'] do
- with_existing_filesystem_scm do |repo_url|
- proj = Project.find(3)
- str = File.read(Rails.root.join('spec/fixtures/encoding/iso-8859-1.txt'))
- r = Repository::Filesystem.create!(project: proj,
- url: repo_url,
- log_encoding: 'ISO-8859-1')
- assert r
- c = Changeset.new(repository: r,
- committed_on: Time.now,
- revision: '123',
- scmid: '12345',
- comments: str)
- assert(c.save)
- assert_equal 'Texte encodé en ISO-8859-1.', c.comments
- end
- end
- end
-
- it 'should invalid utf8 sequences in comments should be replaced latin1' do
- with_settings enabled_scm: ['Filesystem'] do
- with_existing_filesystem_scm do |repo_url|
- proj = Project.find(3)
- str = File.read(Rails.root.join('spec/fixtures/encoding/iso-8859-1.txt'))
- r = Repository::Filesystem.create!(project: proj,
- url: repo_url,
- log_encoding: 'UTF-8')
- assert r
- c = Changeset.new(repository: r,
- committed_on: Time.now,
- revision: '123',
- scmid: '12345',
- comments: str)
- assert(c.save)
- assert_equal 'Texte encod? en ISO-8859-1.', c.comments
- end
- end
- end
-
- it 'should invalid utf8 sequences in comments should be replaced ja jis' do
- with_settings enabled_scm: ['Filesystem'] do
- with_existing_filesystem_scm do |repo_url|
- proj = Project.find(3)
- str = "test\xb5\xfetest\xb5\xfe"
- if str.respond_to?(:force_encoding)
- str.force_encoding('ASCII-8BIT')
- end
- r = Repository::Filesystem.create!(project: proj,
- url: repo_url,
- log_encoding: 'ISO-2022-JP')
- assert r
- c = Changeset.new(repository: r,
- committed_on: Time.now,
- revision: '123',
- scmid: '12345',
- comments: str)
- assert(c.save)
- assert_equal 'test??test??', c.comments
- end
- end
- end
-
- it 'should comments should be converted all latin1 to utf8' do
- with_settings enabled_scm: ['Filesystem'] do
- with_existing_filesystem_scm do |repo_url|
- s1 = "\xC2\x80"
- s2 = "\xc3\x82\xc2\x80"
- s4 = s2.dup
- if s1.respond_to?(:force_encoding)
- s3 = s1.dup
- s1.force_encoding('ASCII-8BIT')
- s2.force_encoding('ASCII-8BIT')
- s3.force_encoding('ISO-8859-1')
- s4.force_encoding('UTF-8')
- assert_equal s3.encode('UTF-8'), s4
- end
- proj = Project.find(3)
- r = Repository::Filesystem.create!(project: proj,
- url: repo_url,
- log_encoding: 'ISO-8859-1')
- assert r
- c = Changeset.new(repository: r,
- committed_on: Time.now,
- revision: '123',
- scmid: '12345',
- comments: s1)
- assert(c.save)
- assert_equal s4, c.comments
- end
- end
- end
-
it 'should comments nil' do
- with_settings enabled_scm: ['Filesystem'] do
- with_existing_filesystem_scm do |repo_url|
- proj = Project.find(3)
- r = Repository::Filesystem.create!(project: proj,
- url: repo_url,
- log_encoding: 'ISO-8859-1')
- assert r
- c = Changeset.new(repository: r,
- committed_on: Time.now,
- revision: '123',
- scmid: '12345',
- comments: nil)
- assert(c.save)
- assert_equal '', c.comments
- if c.comments.respond_to?(:force_encoding)
- assert_equal 'UTF-8', c.comments.encoding.to_s
- end
+ with_settings enabled_scm: ['Subversion'] do
+ proj = Project.find(3)
+ r = FactoryGirl.create(:repository_subversion,
+ project: proj)
+ assert r
+
+ c = Changeset.new(repository: r,
+ committed_on: Time.now,
+ revision: '123',
+ scmid: '12345',
+ comments: nil)
+ assert(c.save)
+ assert_equal '', c.comments
+ if c.comments.respond_to?(:force_encoding)
+ assert_equal 'UTF-8', c.comments.encoding.to_s
end
end
end
it 'should comments empty' do
- with_settings enabled_scm: ['Filesystem'] do
- with_existing_filesystem_scm do |repo_url|
+ with_settings enabled_scm: ['Subversion'] do
proj = Project.find(3)
- r = Repository::Filesystem.create!(project: proj,
- url: repo_url,
- log_encoding: 'ISO-8859-1')
+ r = FactoryGirl.create(:repository_subversion)
+
assert r
c = Changeset.new(repository: r,
committed_on: Time.now,
@@ -370,7 +274,6 @@ describe Changeset, type: :model do
end
end
end
- end
it 'should identifier' do
c = Changeset.find_by(revision: '1')
diff --git a/spec/legacy/unit/comment_spec.rb b/spec/legacy/unit/comment_spec.rb
index 9a2929abe32..2bc7248c7c7 100644
--- a/spec/legacy/unit/comment_spec.rb
+++ b/spec/legacy/unit/comment_spec.rb
@@ -81,13 +81,13 @@ describe Comment, type: :model do
news = FactoryGirl.create(:news, project: project, author: user)
# with notifications for that event turned on
- allow(Notifier).to receive(:notify?).with(:news_comment_added).and_return(true)
+ allow(Setting).to receive(:notified_events).and_return(['news_comment_added'])
assert_difference 'ActionMailer::Base.deliveries.size', 1 do
Comment.create!(commented: news, author: user, comments: 'more useful stuff')
end
# with notifications for that event turned off
- allow(Notifier).to receive(:notify?).with(:news_comment_added).and_return(false)
+ allow(Setting).to receive(:notified_events).and_return([])
assert_no_difference 'ActionMailer::Base.deliveries.size' do
Comment.create!(commented: news, author: user, comments: 'more useful stuff')
end
diff --git a/spec/legacy/unit/helpers/application_helper_spec.rb b/spec/legacy/unit/helpers/application_helper_spec.rb
index 6d6905e1961..ae5bb6f34a8 100644
--- a/spec/legacy/unit/helpers/application_helper_spec.rb
+++ b/spec/legacy/unit/helpers/application_helper_spec.rb
@@ -194,40 +194,38 @@ RAW
'invalid:version:"1.0"' => 'invalid:version:"1.0"'
}
- with_existing_filesystem_scm do |repo_path|
- repository = FactoryGirl.create :repository,
- url: repo_path,
- project: @project
- changeset = FactoryGirl.create :changeset,
- repository: repository,
- comments: 'This commit fixes #1, #2 and references #1 & #3'
- identifier = @project.identifier
+ repository = FactoryGirl.create :repository_subversion,
+ project: @project
- source_link = link_to("#{identifier}:source:/some/file",
- { controller: 'repositories',
- action: 'entry',
- project_id: identifier,
- path: 'some/file' },
- class: 'source')
- changeset_link = link_to("#{identifier}:r#{changeset.revision}",
- { controller: 'repositories',
- action: 'revision',
- project_id: identifier,
- rev: changeset.revision },
- class: 'changeset',
- title: 'This commit fixes #1, #2 and references #1 & #3')
+ changeset = FactoryGirl.create :changeset,
+ repository: repository,
+ comments: 'This commit fixes #1, #2 and references #1 & #3'
+ identifier = @project.identifier
- to_test.merge!(
- # changeset
- "r#{changeset.revision}" => "r#{changeset.revision}",
- "#{@project.identifier}:r#{changeset.revision}" => changeset_link,
- "invalid:r#{changeset.revision}" => "invalid:r#{changeset.revision}",
- # source
- 'source:/some/file' => 'source:/some/file',
- "#{@project.identifier}:source:/some/file" => source_link,
- 'invalid:source:/some/file' => 'invalid:source:/some/file',
- )
- end
+ source_link = link_to("#{identifier}:source:/some/file",
+ { controller: 'repositories',
+ action: 'entry',
+ project_id: identifier,
+ path: 'some/file' },
+ class: 'source')
+ changeset_link = link_to("#{identifier}:r#{changeset.revision}",
+ { controller: 'repositories',
+ action: 'revision',
+ project_id: identifier,
+ rev: changeset.revision },
+ class: 'changeset',
+ title: 'This commit fixes #1, #2 and references #1 & #3')
+
+ to_test.merge!(
+ # changeset
+ "r#{changeset.revision}" => "r#{changeset.revision}",
+ "#{@project.identifier}:r#{changeset.revision}" => changeset_link,
+ "invalid:r#{changeset.revision}" => "invalid:r#{changeset.revision}",
+ # source
+ 'source:/some/file' => 'source:/some/file',
+ "#{@project.identifier}:source:/some/file" => source_link,
+ 'invalid:source:/some/file' => 'invalid:source:/some/file',
+ )
# helper.format_text "sees" the text is parses from the_other_project (and not @project)
the_other_project = FactoryGirl.create :valid_project
@@ -251,7 +249,7 @@ RAW
to_test = {
'commit:abcd' => changeset_link,
}
- r = Repository::Git.create!(project: @project, url: '/tmp/test/git')
+ r = Repository::Git.create!(project: @project, scm_type: 'local', url: '/tmp/test/git')
assert r
c = Changeset.new(repository: r,
committed_on: Time.now,
diff --git a/spec/legacy/unit/issue_nested_set_spec.rb b/spec/legacy/unit/issue_nested_set_spec.rb
index f9d5b06e3b4..133130a3391 100644
--- a/spec/legacy/unit/issue_nested_set_spec.rb
+++ b/spec/legacy/unit/issue_nested_set_spec.rb
@@ -77,7 +77,8 @@ describe 'IssueNestedSet', type: :model do
assert_equal [1, parent1.id, 5], [parent1.project_id, parent1.root_id, parent1.nested_set_span]
# child can not be moved to Project 2 because its child is on a disabled type
- assert_equal false, WorkPackage.find(child.id).move_to_project(Project.find(2))
+ service = MoveWorkPackageService.new(child, User.current)
+ assert_equal false, service.call(Project.find(2))
child.reload
grandchild.reload
parent1.reload
diff --git a/spec/legacy/unit/journal_observer_spec.rb b/spec/legacy/unit/journal_observer_spec.rb
deleted file mode 100644
index d6f78a2c37f..00000000000
--- a/spec/legacy/unit/journal_observer_spec.rb
+++ /dev/null
@@ -1,133 +0,0 @@
-#-- encoding: UTF-8
-#-- copyright
-# OpenProject is a project management system.
-# Copyright (C) 2012-2015 the OpenProject Foundation (OPF)
-#
-# This program is free software; you can redistribute it and/or
-# modify it under the terms of the GNU General Public License version 3.
-#
-# OpenProject is a fork of ChiliProject, which is a fork of Redmine. The copyright follows:
-# Copyright (C) 2006-2013 Jean-Philippe Lang
-# Copyright (C) 2010-2013 the ChiliProject Team
-#
-# This program is free software; you can redistribute it and/or
-# modify it under the terms of the GNU General Public License
-# as published by the Free Software Foundation; either version 2
-# of the License, or (at your option) any later version.
-#
-# This program is distributed in the hope that it will be useful,
-# but WITHOUT ANY WARRANTY; without even the implied warranty of
-# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
-# GNU General Public License for more details.
-#
-# You should have received a copy of the GNU General Public License
-# along with this program; if not, write to the Free Software
-# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
-#
-# See doc/COPYRIGHT.rdoc for more details.
-#++
-require 'legacy_spec_helper'
-
-describe JournalObserver, type: :model do
- before do
- @type = FactoryGirl.create :type_with_workflow
- @project = FactoryGirl.create :project,
- types: [@type]
- @workflow = @type.workflows.first
- @user = FactoryGirl.create :user,
- mail_notification: 'all',
- member_in_project: @project
- @issue = FactoryGirl.create :work_package,
- project: @project,
- author: @user,
- type: @type,
- status: @workflow.old_status
-
- @user.members.first.roles << @workflow.role
- @user.reload
-
- allow(User).to receive(:current).and_return(@user)
-
- ActionMailer::Base.deliveries.clear
- end
-
- context "#after_create for 'work_package_updated'" do
- it 'should send a notification when configured as a notification' do
- Setting.notified_events = ['work_package_updated']
- assert_difference('ActionMailer::Base.deliveries.size', +1) do
- @issue.add_journal(@user)
- @issue.subject = 'A change to the issue'
- assert @issue.save(validate: false)
- end
- end
-
- it 'should not send a notification with not configured' do
- Setting.notified_events = []
- assert_no_difference('ActionMailer::Base.deliveries.size') do
- @issue.add_journal(@user)
- @issue.subject = 'A change to the issue'
- assert @issue.save(validate: false)
- end
- end
- end
-
- context "#after_create for 'work_package_note_added'" do
- it 'should send a notification when configured as a notification' do
- @issue.recreate_initial_journal!
-
- Setting.notified_events = ['work_package_note_added']
- assert_difference('ActionMailer::Base.deliveries.size', +1) do
- @issue.add_journal(@user, 'This update has a note')
- assert @issue.save(validate: false)
- end
- end
-
- it 'should not send a notification with not configured' do
- Setting.notified_events = []
- assert_no_difference('ActionMailer::Base.deliveries.size') do
- @issue.add_journal(@user, 'This update has a note')
- assert @issue.save(validate: false)
- end
- end
- end
-
- context "#after_create for 'status_updated'" do
- it 'should send a notification when configured as a notification' do
- Setting.notified_events = ['status_updated']
- assert_difference('ActionMailer::Base.deliveries.size', +1) do
- @issue.add_journal(@user)
- @issue.status = @workflow.new_status
- assert @issue.save(validate: false)
- end
- end
-
- it 'should not send a notification with not configured' do
- Setting.notified_events = []
- assert_no_difference('ActionMailer::Base.deliveries.size') do
- @issue.add_journal(@user)
- @issue.status = @workflow.new_status
- assert @issue.save(validate: false)
- end
- end
- end
-
- context "#after_create for 'work_package_priority_updated'" do
- it 'should send a notification when configured as a notification' do
- Setting.notified_events = ['work_package_priority_updated']
- assert_difference('ActionMailer::Base.deliveries.size', +1) do
- @issue.add_journal(@user)
- @issue.priority = IssuePriority.generate!
- assert @issue.save(validate: false)
- end
- end
-
- it 'should not send a notification with not configured' do
- Setting.notified_events = []
- assert_no_difference('ActionMailer::Base.deliveries.size') do
- @issue.add_journal(@user)
- @issue.priority = IssuePriority.generate!
- assert @issue.save(validate: false)
- end
- end
- end
-end
diff --git a/spec/legacy/unit/journal_spec.rb b/spec/legacy/unit/journal_spec.rb
index 25efc49624c..260696ff8ad 100644
--- a/spec/legacy/unit/journal_spec.rb
+++ b/spec/legacy/unit/journal_spec.rb
@@ -37,26 +37,24 @@ describe Journal, type: :model do
end
end
- it 'should create should send email notification' do
- ActionMailer::Base.deliveries.clear
- issue = WorkPackage.first
+ it 'create should send email notification' do
+ issue = WorkPackage.find(:first)
if issue.journals.empty?
issue.add_journal(User.current, 'This journal represents the creationa of journal version 1')
issue.save
end
- user = User.first
- assert_equal 0, ActionMailer::Base.deliveries.size
+ ActionMailer::Base.deliveries.clear
issue.reload
issue.update_attribute(:subject, 'New subject to trigger automatic journal entry')
assert_equal 2, ActionMailer::Base.deliveries.size
end
- it 'should create should not send email notification if told not to' do
+ it 'create should not send email notification if told not to' do
ActionMailer::Base.deliveries.clear
issue = WorkPackage.first
user = User.first
journal = issue.add_journal(user, 'A note')
- JournalObserver.instance.send_notification = false
+ JournalManager.send_notification = false
assert_difference('Journal.count') do
assert issue.save
@@ -80,9 +78,9 @@ describe Journal, type: :model do
end
journal = issue.reload.journals.first
- assert_equal [nil, 'Test initial journal'], journal.changed_data[:subject]
- assert_equal [nil, @project.id], journal.changed_data[:project_id]
- assert_equal [nil, 'Some content'], journal.changed_data[:description]
+ assert_equal [nil, 'Test initial journal'], journal.details[:subject]
+ assert_equal [nil, @project.id], journal.details[:project_id]
+ assert_equal [nil, 'Some content'], journal.details[:description]
end
specify 'creating a journal should update the updated_on value of the parent record (touch)' do
diff --git a/spec/legacy/unit/lib/redmine/hook_spec.rb b/spec/legacy/unit/lib/redmine/hook_spec.rb
index db0029e26d2..389d79ea0b2 100644
--- a/spec/legacy/unit/lib/redmine/hook_spec.rb
+++ b/spec/legacy/unit/lib/redmine/hook_spec.rb
@@ -156,17 +156,17 @@ describe 'Redmine::Hook::Manager' do # FIXME: naming (RSpec-port)
it 'should call_hook_should_not_change_the_default_url_for_email_notifications' do
user = User.find(1)
- issue = WorkPackage.find(1)
+ issue = FactoryGirl.create(:work_package)
ActionMailer::Base.deliveries.clear
- UserMailer.work_package_added(user, issue, user).deliver
+ UserMailer.work_package_added(user, issue.journals.first, user).deliver
mail = ActionMailer::Base.deliveries.last
@hook_module.add_listener(TestLinkToHook)
hook_helper.call_hook(:view_layouts_base_html_head)
ActionMailer::Base.deliveries.clear
- UserMailer.work_package_added(user, issue, user).deliver
+ UserMailer.work_package_added(user, issue.journals.first, user).deliver
mail2 = ActionMailer::Base.deliveries.last
assert_equal mail.text_part.body.encoded, mail2.text_part.body.encoded
diff --git a/spec/legacy/unit/lib/redmine/scm/adapters/filesystem_adapter_spec.rb b/spec/legacy/unit/lib/redmine/scm/adapters/filesystem_adapter_spec.rb
deleted file mode 100644
index 49fbb672499..00000000000
--- a/spec/legacy/unit/lib/redmine/scm/adapters/filesystem_adapter_spec.rb
+++ /dev/null
@@ -1,62 +0,0 @@
-#-- encoding: UTF-8
-#-- copyright
-# OpenProject is a project management system.
-# Copyright (C) 2012-2015 the OpenProject Foundation (OPF)
-#
-# This program is free software; you can redistribute it and/or
-# modify it under the terms of the GNU General Public License version 3.
-#
-# OpenProject is a fork of ChiliProject, which is a fork of Redmine. The copyright follows:
-# Copyright (C) 2006-2013 Jean-Philippe Lang
-# Copyright (C) 2010-2013 the ChiliProject Team
-#
-# This program is free software; you can redistribute it and/or
-# modify it under the terms of the GNU General Public License
-# as published by the Free Software Foundation; either version 2
-# of the License, or (at your option) any later version.
-#
-# This program is distributed in the hope that it will be useful,
-# but WITHOUT ANY WARRANTY; without even the implied warranty of
-# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
-# GNU General Public License for more details.
-#
-# You should have received a copy of the GNU General Public License
-# along with this program; if not, write to the Free Software
-# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
-#
-# See doc/COPYRIGHT.rdoc for more details.
-#++
-
-require 'legacy_spec_helper'
-
-describe Redmine::Scm::Adapters::FilesystemAdapter, type: :model do
- let(:fs_repository_path) { Rails.root.to_s.gsub(%r{config\/\.\.}, '') + '/tmp/test/filesystem_repository' }
-
- before do
- skip 'Filesystem test repository NOT FOUND. Skipping unit tests !!! See doc/RUNNING_TESTS.' unless File.directory?(fs_repository_path)
-
- @adapter = Redmine::Scm::Adapters::FilesystemAdapter.new(fs_repository_path)
- end
-
- it 'should entries' do
- assert_equal 3, @adapter.entries.size
- assert_equal ['dir', 'japanese', 'test'], @adapter.entries.map(&:name)
- assert_equal ['dir', 'japanese', 'test'], @adapter.entries(nil).map(&:name)
- assert_equal ['dir', 'japanese', 'test'], @adapter.entries('/').map(&:name)
- ['dir', '/dir', '/dir/', 'dir/'].each do |path|
- assert_equal ['subdir', 'dirfile'], @adapter.entries(path).map(&:name)
- end
- # If y try to use "..", the path is ignored
- ['/../', 'dir/../', '..', '../', '/..', 'dir/..'].each do |path|
- assert_equal ['dir', 'japanese', 'test'], @adapter.entries(path).map(&:name),
- '.. must be ignored in path argument'
- end
- end
-
- it 'should cat' do
- assert_equal "TEST CAT\n", @adapter.cat('test')
- assert_equal "TEST CAT\n", @adapter.cat('/test')
- # Revision number is ignored
- assert_equal "TEST CAT\n", @adapter.cat('/test', 1)
- end
-end
diff --git a/spec/legacy/unit/lib/redmine/scm/adapters/git_adapter_spec.rb b/spec/legacy/unit/lib/redmine/scm/adapters/git_adapter_spec.rb
index aa986c103f9..d56e5e55ec2 100644
--- a/spec/legacy/unit/lib/redmine/scm/adapters/git_adapter_spec.rb
+++ b/spec/legacy/unit/lib/redmine/scm/adapters/git_adapter_spec.rb
@@ -32,7 +32,7 @@
require 'legacy_spec_helper'
-describe Redmine::Scm::Adapters::GitAdapter, type: :model do
+describe OpenProject::Scm::Adapters::Git, type: :model do
let(:git_repository_path) { Rails.root.to_s.gsub(%r{config\/\.\.}, '') + '/tmp/test/git_repository' }
FELIX_UTF8 = 'Felix Schäfer'
@@ -48,7 +48,7 @@ describe Redmine::Scm::Adapters::GitAdapter, type: :model do
before do
skip 'Git test repository NOT FOUND. Skipping unit tests !!!' unless File.directory?(git_repository_path)
- @adapter = Redmine::Scm::Adapters::GitAdapter.new(
+ @adapter = OpenProject::Scm::Adapters::Git.new(
git_repository_path,
nil,
nil,
@@ -88,7 +88,7 @@ describe Redmine::Scm::Adapters::GitAdapter, type: :model do
end
it 'should getting all revisions' do
- assert_equal 21, @adapter.revisions('', nil, nil, all: true).length
+ assert_equal 22, @adapter.revisions('', nil, nil, all: true).length
end
it 'should getting certain revisions' do
@@ -97,15 +97,16 @@ describe Redmine::Scm::Adapters::GitAdapter, type: :model do
it 'should revisions reverse' do
revs1 = @adapter.revisions('', nil, nil, all: true, reverse: true)
- assert_equal 21, revs1.length
+ assert_equal 22, revs1.length
assert_equal '7234cb2750b63f47bff735edc50a1c0a433c2518', revs1[0].identifier
assert_equal '1ca7f5ed374f3cb31a93ae5215c2e25cc6ec5127', revs1[20].identifier
since2 = Time.gm(2010, 9, 30, 0, 0, 0)
revs2 = @adapter.revisions('', nil, nil, all: true, since: since2, reverse: true)
- assert_equal 6, revs2.length
+ assert_equal 7, revs2.length
assert_equal '67e7792ce20ccae2e4bb73eed09bb397819c8834', revs2[0].identifier
assert_equal '1ca7f5ed374f3cb31a93ae5215c2e25cc6ec5127', revs2[5].identifier
+ assert_equal '71e5c1d3dca6304805b143b9d0e6695fb3895ea4', revs2[6].identifier
end
it 'should getting revisions with spaces in filename' do
@@ -127,7 +128,7 @@ describe Redmine::Scm::Adapters::GitAdapter, type: :model do
it 'should annotate' do
annotate = @adapter.annotate('sources/watchers_controller.rb')
- assert_kind_of Redmine::Scm::Adapters::Annotate, annotate
+ assert_kind_of OpenProject::Scm::Adapters::Annotate, annotate
assert_equal 41, annotate.lines.size
assert_equal '# This program is free software; you can redistribute it and/or',
annotate.lines[4].strip
@@ -138,7 +139,7 @@ describe Redmine::Scm::Adapters::GitAdapter, type: :model do
it 'should annotate moved file' do
annotate = @adapter.annotate('renamed_test.txt')
- assert_kind_of Redmine::Scm::Adapters::Annotate, annotate
+ assert_kind_of OpenProject::Scm::Adapters::Annotate, annotate
assert_equal 2, annotate.lines.size
end
@@ -169,8 +170,7 @@ describe Redmine::Scm::Adapters::GitAdapter, type: :model do
assert_equal '2010-09-18 19:59:46'.to_time, last_rev.time
end
- # TODO: need to handle edge cases of non-binary content that isn't UTF-8
- xit 'test latin 1 path' do
+ it 'test latin 1 path' do
if WINDOWS_PASS
#
else
@@ -248,7 +248,7 @@ describe Redmine::Scm::Adapters::GitAdapter, type: :model do
private
def test_scm_version_for(scm_command_version, version)
- expect(@adapter.class).to receive(:scm_version_from_command_line).and_return(scm_command_version)
- assert_equal version, @adapter.class.scm_command_version
+ expect(@adapter).to receive(:scm_version_from_command_line).and_return(scm_command_version)
+ assert_equal version, @adapter.git_binary_version
end
end
diff --git a/spec/legacy/unit/lib/redmine/scm/adapters/subversion_adapter_spec.rb b/spec/legacy/unit/lib/redmine/scm/adapters/subversion_adapter_spec.rb
index 567dea34597..7083d722481 100644
--- a/spec/legacy/unit/lib/redmine/scm/adapters/subversion_adapter_spec.rb
+++ b/spec/legacy/unit/lib/redmine/scm/adapters/subversion_adapter_spec.rb
@@ -29,14 +29,14 @@
require 'legacy_spec_helper'
-describe Redmine::Scm::Adapters::SubversionAdapter, type: :model do
+describe OpenProject::Scm::Adapters::Subversion, type: :model do
if repository_configured?('subversion')
before do
- @adapter = Redmine::Scm::Adapters::SubversionAdapter.new(self.class.subversion_repository_url)
+ @adapter = OpenProject::Scm::Adapters::Subversion.new(self.class.subversion_repository_url)
end
it 'should client version' do
- v = Redmine::Scm::Adapters::SubversionAdapter.client_version
+ v = @adapter.client_version
assert v.is_a?(Array)
end
@@ -53,8 +53,8 @@ describe Redmine::Scm::Adapters::SubversionAdapter, type: :model do
private
def test_scm_version_for(scm_version, version)
- expect(@adapter.class).to receive(:scm_version_from_command_line).and_return(scm_version)
- assert_equal version, @adapter.class.svn_binary_version
+ expect(@adapter).to receive(:scm_version_from_command_line).and_return(scm_version)
+ assert_equal version, @adapter.svn_binary_version
end
else
diff --git a/spec/legacy/unit/repository_filesystem_spec.rb b/spec/legacy/unit/repository_filesystem_spec.rb
deleted file mode 100644
index 8b1f2e1107d..00000000000
--- a/spec/legacy/unit/repository_filesystem_spec.rb
+++ /dev/null
@@ -1,65 +0,0 @@
-#-- encoding: UTF-8
-#-- copyright
-# OpenProject is a project management system.
-# Copyright (C) 2012-2015 the OpenProject Foundation (OPF)
-#
-# This program is free software; you can redistribute it and/or
-# modify it under the terms of the GNU General Public License version 3.
-#
-# OpenProject is a fork of ChiliProject, which is a fork of Redmine. The copyright follows:
-# Copyright (C) 2006-2013 Jean-Philippe Lang
-# Copyright (C) 2010-2013 the ChiliProject Team
-#
-# This program is free software; you can redistribute it and/or
-# modify it under the terms of the GNU General Public License
-# as published by the Free Software Foundation; either version 2
-# of the License, or (at your option) any later version.
-#
-# This program is distributed in the hope that it will be useful,
-# but WITHOUT ANY WARRANTY; without even the implied warranty of
-# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
-# GNU General Public License for more details.
-#
-# You should have received a copy of the GNU General Public License
-# along with this program; if not, write to the Free Software
-# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
-#
-# See doc/COPYRIGHT.rdoc for more details.
-#++
-require 'legacy_spec_helper'
-
-describe Repository::Filesystem, type: :model do
- fixtures :all
-
- before do
- @project = Project.find(3)
-
- with_existing_filesystem_scm do |repo_path|
- assert @repository = Repository::Filesystem.create(project: @project,
- url: repo_path)
- end
- end
-
- it 'should fetch changesets' do
- with_existing_filesystem_scm do
- @repository.fetch_changesets
- @repository.reload
-
- assert_equal 0, @repository.changesets.count
- assert_equal 0, @repository.changes.count
- end
- end
-
- it 'should entries' do
- with_existing_filesystem_scm do
- assert_equal 3, @repository.entries('', 2).size
- assert_equal 2, @repository.entries('dir', 3).size
- end
- end
-
- it 'should cat' do
- with_existing_filesystem_scm do
- assert_equal "TEST CAT\n", @repository.scm.cat('test')
- end
- end
-end
diff --git a/spec/legacy/unit/repository_git_spec.rb b/spec/legacy/unit/repository_git_spec.rb
index 89911c55a0a..d4dafe3742b 100644
--- a/spec/legacy/unit/repository_git_spec.rb
+++ b/spec/legacy/unit/repository_git_spec.rb
@@ -53,6 +53,7 @@ describe Repository::Git, type: :model do
@project = Project.find(3)
@repository = Repository::Git.create(
project: @project,
+ scm_type: 'local',
url: git_repository_path,
path_encoding: 'ISO-8859-1'
)
@@ -67,8 +68,8 @@ describe Repository::Git, type: :model do
@repository.fetch_changesets
@repository.reload
- assert_equal 21, @repository.changesets.count
- assert_equal 33, @repository.changes.count
+ assert_equal 22, @repository.changesets.count
+ assert_equal 34, @repository.changes.count
commit = @repository.changesets.reorder('committed_on ASC').first
assert_equal "Initial import.\nThe repository contains 3 files.", commit.comments
@@ -91,19 +92,19 @@ describe Repository::Git, type: :model do
@repository.changesets.order('committed_on DESC').limit(8).each(&:destroy)
@repository.reload
cs1 = @repository.changesets
- assert_equal 13, cs1.count
+ assert_equal 14, cs1.count
rev_a_commit = @repository.changesets.order('committed_on DESC').first
- assert_equal '4f26664364207fa8b1af9f8722647ab2d4ac5d43', rev_a_commit.revision
+ assert_equal 'ed5bb786bbda2dee66a2d50faf51429dbc043a7b', rev_a_commit.revision
# Mon Jul 5 22:34:26 2010 +0200
- rev_a_committed_on = Time.gm(2010, 7, 5, 20, 34, 26)
- assert_equal '4f26664364207fa8b1af9f8722647ab2d4ac5d43', rev_a_commit.scmid
+ rev_a_committed_on = Time.gm(2010, 9, 18, 19, 59, 46)
+ assert_equal 'ed5bb786bbda2dee66a2d50faf51429dbc043a7b', rev_a_commit.scmid
assert_equal rev_a_committed_on, rev_a_commit.committed_on
latest_rev = @repository.latest_changeset
assert_equal rev_a_committed_on, latest_rev.committed_on
@repository.fetch_changesets
- assert_equal 21, @repository.changesets.count
+ assert_equal 22, @repository.changesets.count
end
it 'should latest changesets' do
@@ -320,7 +321,7 @@ describe Repository::Git, type: :model do
it 'should next nil' do
@repository.fetch_changesets
@repository.reload
- %w|1ca7f5ed374f3cb31a93ae5215c2e25cc6ec5127 1ca7f5ed|.each do |r1|
+ %w|71e5c1d3dca6304805b143b9d0e6695fb3895ea4 71e5c1d3|.each do |r1|
changeset = @repository.find_changeset_by_name(r1)
assert_nil changeset.next
end
diff --git a/spec/legacy/unit/repository_spec.rb b/spec/legacy/unit/repository_spec.rb
index 9fbc75cc15d..da979020d85 100644
--- a/spec/legacy/unit/repository_spec.rb
+++ b/spec/legacy/unit/repository_spec.rb
@@ -42,7 +42,7 @@ describe Repository, type: :model do
end
it 'should create' do
- repository = Repository::Subversion.new(project: Project.find(3))
+ repository = Repository::Subversion.new(project: Project.find(3), scm_type: 'existing')
assert !repository.save
repository.url = 'svn://localhost'
@@ -68,7 +68,7 @@ describe Repository, type: :model do
it 'should not create with disabled scm' do
Setting.enabled_scm = ['Git'] # disable Subversion
- repository = Repository::Subversion.new(project: Project.find(3), url: 'svn://localhost')
+ repository = Repository::Subversion.new(project: Project.find(3), scm_type: 'existing', url: 'svn://localhost')
assert !repository.save
assert_includes repository.errors[:type], I18n.translate('activerecord.errors.messages.invalid')
# re-enable Subversion for following tests
@@ -120,7 +120,11 @@ describe Repository, type: :model do
end
it 'should for changeset comments strip' do
- repository = Repository::Subversion.create(project: Project.find(4), url: 'svn://:login:password@host:/path/to/the/repository')
+ repository = Repository::Subversion.create(
+ project: Project.find(4),
+ scm_type: 'existing',
+ url: 'svn://:login:password@host:/path/to/the/repository'
+ )
comment = 'This is a looooooooooooooong comment' + (' ' * 80 + "\n") * 5
changeset = Changeset.new(
comments: comment, commit_date: Time.now, revision: 0, scmid: 'f39b7922fb3c',
@@ -134,6 +138,7 @@ describe Repository, type: :model do
repository = Repository::Subversion.create(
project: Project.find(4),
url: ' svn://:login:password@host:/path/to/the/repository',
+ scm_type: 'existing',
log_encoding: 'UTF-8')
repository.root_url = 'foo ' # can't mass-assign this attr
assert repository.save
diff --git a/spec/legacy/unit/repository_subversion_spec.rb b/spec/legacy/unit/repository_subversion_spec.rb
index f850daac219..297fb95c0dd 100644
--- a/spec/legacy/unit/repository_subversion_spec.rb
+++ b/spec/legacy/unit/repository_subversion_spec.rb
@@ -36,6 +36,7 @@ describe Repository::Subversion, type: :model do
@project = Project.find(3)
@repository = Repository::Subversion.create(project: @project,
+ scm_type: 'existing',
url: self.class.subversion_repository_url)
assert @repository
end
@@ -44,8 +45,8 @@ describe Repository::Subversion, type: :model do
@repository.fetch_changesets
@repository.reload
- assert_equal 11, @repository.changesets.count
- assert_equal 20, @repository.changes.count
+ assert_equal 12, @repository.changesets.count
+ assert_equal 21, @repository.changes.count
assert_equal 'Initial import.', @repository.changesets.find_by(revision: '1').comments
end
@@ -57,7 +58,7 @@ describe Repository::Subversion, type: :model do
assert_equal 5, @repository.changesets.count
@repository.fetch_changesets
- assert_equal 11, @repository.changesets.count
+ assert_equal 12, @repository.changesets.count
end
it 'should latest changesets' do
@@ -91,6 +92,7 @@ describe Repository::Subversion, type: :model do
@project = Project.find(3)
@repository = Repository::Subversion.create(
project: @project,
+ scm_type: 'local',
url: "file:///#{self.class.repository_path('subversion')}/subversion_test/[folder_with_brackets]")
@repository.fetch_changesets
@@ -197,7 +199,7 @@ describe Repository::Subversion, type: :model do
it 'should next nil' do
@repository.fetch_changesets
@repository.reload
- changeset = @repository.find_changeset_by_name('11')
+ changeset = @repository.find_changeset_by_name('12')
assert_nil changeset.next
end
diff --git a/spec/legacy/unit/user_spec.rb b/spec/legacy/unit/user_spec.rb
index 0f87ad4dab3..181565a50d1 100644
--- a/spec/legacy/unit/user_spec.rb
+++ b/spec/legacy/unit/user_spec.rb
@@ -371,7 +371,7 @@ describe User, type: :model do
@jsmith.notified_project_ids = []
@jsmith.save
@jsmith.reload
- assert @jsmith.projects.first.recipients.include?(@jsmith.mail)
+ assert @jsmith.projects.first.recipients.include?(@jsmith)
end
it 'should mail notification selected' do
@@ -379,7 +379,7 @@ describe User, type: :model do
@jsmith.notified_project_ids = [1]
@jsmith.save
@jsmith.reload
- assert Project.find(1).recipients.include?(@jsmith.mail)
+ assert Project.find(1).recipients.include?(@jsmith)
end
it 'should mail notification only my events' do
@@ -387,7 +387,7 @@ describe User, type: :model do
@jsmith.notified_project_ids = []
@jsmith.save
@jsmith.reload
- assert !@jsmith.projects.first.recipients.include?(@jsmith.mail)
+ assert !@jsmith.projects.first.recipients.include?(@jsmith)
end
it 'should comments sorting preference' do
diff --git a/spec/legacy/unit/watcher_spec.rb b/spec/legacy/unit/watcher_spec.rb
index 15c1c12d6b2..4536b0613f4 100644
--- a/spec/legacy/unit/watcher_spec.rb
+++ b/spec/legacy/unit/watcher_spec.rb
@@ -90,10 +90,10 @@ describe Watcher do
assert @issue.watcher_recipients.empty?
assert @issue.add_watcher(@user)
- assert_contains @issue.watcher_recipients, @user.mail
+ assert_contains @issue.watcher_recipients, @user
@user.update_attribute :mail_notification, 'none'
- assert_does_not_contain @issue.watcher_recipients, @user.mail
+ assert_does_not_contain @issue.watcher_recipients, @user
end
it 'should unwatch' do
diff --git a/spec/legacy_spec_helper.rb b/spec/legacy_spec_helper.rb
index 348267dfe50..2dd6c1ec5bd 100644
--- a/spec/legacy_spec_helper.rb
+++ b/spec/legacy_spec_helper.rb
@@ -40,8 +40,10 @@ require 'factory_girl_rails'
require_relative './support/file_helpers'
require_relative './support/shared/with_mock_request'
require_relative './legacy/support/legacy_assertions'
+require_relative './support/repository_helpers'
require_relative './legacy/support/object_daddy_helpers'
+
include ObjectDaddyHelpers
require 'rspec/rails'
diff --git a/spec/lib/acts_as_journalized/journaled_spec.rb b/spec/lib/acts_as_journalized/journaled_spec.rb
index fac3dafc55a..c311d59bd53 100644
--- a/spec/lib/acts_as_journalized/journaled_spec.rb
+++ b/spec/lib/acts_as_journalized/journaled_spec.rb
@@ -96,7 +96,8 @@ describe 'Journalized Objects' do
it 'should work with changesets' do
Setting.enabled_scm = ['Subversion']
- @repository ||= Repository.factory('Subversion', url: 'http://svn.test.com')
+ @repository ||= Repository.build_scm_class('Subversion').new
+ @repository.assign_attributes(scm_type: 'existing', url: 'http://svn.test.com')
@repository.save!
@changeset ||= FactoryGirl.create(:changeset, committer: @current.login, repository: @repository)
diff --git a/spec/lib/api/v3/activities/activity_representer_spec.rb b/spec/lib/api/v3/activities/activity_representer_spec.rb
index 379c08727f2..513c8a44dd3 100644
--- a/spec/lib/api/v3/activities/activity_representer_spec.rb
+++ b/spec/lib/api/v3/activities/activity_representer_spec.rb
@@ -31,12 +31,17 @@ require 'spec_helper'
describe ::API::V3::Activities::ActivityRepresenter do
let(:current_user) { FactoryGirl.create(:user, member_in_project: project, member_through_role: role) }
let(:work_package) { FactoryGirl.build(:work_package) }
- let(:journal) { FactoryGirl.build(:work_package_journal, journable: work_package, user: current_user) }
+ let(:journal) { Journal::AggregatedJournal.aggregated_journals.first }
let(:project) { work_package.project }
let(:permissions) { %i(edit_own_work_package_notes) }
let(:role) { FactoryGirl.create :role, permissions: permissions }
let(:representer) { described_class.new(journal, current_user: current_user) }
+ before do
+ allow(User).to receive(:current).and_return(current_user)
+ work_package.save!
+ end
+
context 'generation' do
subject(:generated) { representer.to_json }
diff --git a/spec/lib/api/v3/repositories/revision_representer_spec.rb b/spec/lib/api/v3/repositories/revision_representer_spec.rb
new file mode 100644
index 00000000000..08847fda85c
--- /dev/null
+++ b/spec/lib/api/v3/repositories/revision_representer_spec.rb
@@ -0,0 +1,127 @@
+#-- copyright
+# OpenProject is a project management system.
+# Copyright (C) 2012-2015 the OpenProject Foundation (OPF)
+#
+# This program is free software; you can redistribute it and/or
+# modify it under the terms of the GNU General Public License version 3.
+#
+# OpenProject is a fork of ChiliProject, which is a fork of Redmine. The copyright follows:
+# Copyright (C) 2006-2013 Jean-Philippe Lang
+# Copyright (C) 2010-2013 the ChiliProject Team
+#
+# This program is free software; you can redistribute it and/or
+# modify it under the terms of the GNU General Public License
+# as published by the Free Software Foundation; either version 2
+# of the License, or (at your option) any later version.
+#
+# This program is distributed in the hope that it will be useful,
+# but WITHOUT ANY WARRANTY; without even the implied warranty of
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+# GNU General Public License for more details.
+#
+# You should have received a copy of the GNU General Public License
+# along with this program; if not, write to the Free Software
+# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
+#
+# See doc/COPYRIGHT.rdoc for more details.
+#++
+
+require 'spec_helper'
+
+describe ::API::V3::Repositories::RevisionRepresenter do
+ include ::API::V3::Utilities::PathHelper
+
+ let(:representer) { described_class.new(revision) }
+
+ let(:project) { FactoryGirl.build :project }
+ let(:repository) { FactoryGirl.build :repository_subversion, project: project }
+ let(:revision) {
+ FactoryGirl.build(:changeset,
+ id: 42,
+ revision: '1234',
+ repository: repository,
+ comments: commit_message,
+ committer: 'foo bar ',
+ committed_on: DateTime.now,
+ )
+ }
+
+ let(:commit_message) { 'Some commit message' }
+
+ context 'generation' do
+ subject(:generated) { representer.to_json }
+
+ it { is_expected.to be_json_eql('Revision'.to_json).at_path('_type') }
+
+ describe 'revision' do
+ it { is_expected.to have_json_path('id') }
+
+ it_behaves_like 'API V3 formattable', 'message' do
+ let(:format) { 'plain' }
+ let(:raw) { revision.comments }
+ let(:html) { '
' + revision.comments + '
' }
+ end
+
+ describe 'identifier' do
+ it { is_expected.to have_json_path('identifier') }
+ it { is_expected.to be_json_eql('1234'.to_json).at_path('identifier') }
+ end
+
+ describe 'createdAt' do
+ it_behaves_like 'has UTC ISO 8601 date and time' do
+ let(:date) { revision.committed_on }
+ let(:json_path) { 'createdAt' }
+ end
+ end
+
+ describe 'authorName' do
+ it { is_expected.to have_json_path('authorName') }
+ it { is_expected.to be_json_eql('foo bar '.to_json).at_path('authorName') }
+ end
+ end
+
+ context 'with referencing commit message' do
+ let(:work_package) { FactoryGirl.build_stubbed(:work_package, project: project) }
+ let(:commit_message) { "Totally references ##{work_package.id}" }
+ let(:html_reference) {
+ id = work_package.id
+
+ str = "Totally references "
+ str << "##{id}"
+ }
+
+ before do
+ allow(WorkPackage).to receive(:find_by_id).and_return(work_package)
+ end
+
+ it_behaves_like 'API V3 formattable', 'message' do
+ let(:format) { 'plain' }
+ let(:raw) { revision.comments }
+ let(:html) { '
' + html_reference + '
' }
+ end
+ end
+
+ describe 'author' do
+ context 'with no linked user' do
+ it_behaves_like 'has no link' do
+ let(:link) { 'author' }
+ end
+ end
+
+ context 'with linked user as author' do
+ let(:user) { FactoryGirl.build(:user) }
+ before do
+ allow(revision).to receive(:user).and_return(user)
+ end
+
+ it_behaves_like 'has a titled link' do
+ let(:link) { 'author' }
+ let(:href) { api_v3_paths.user(user.id) }
+ let(:title) { user.name }
+ end
+ end
+ end
+ end
+end
diff --git a/spec/lib/api/v3/utilities/path_helper_spec.rb b/spec/lib/api/v3/utilities/path_helper_spec.rb
index f91c65b5d45..a8f7808ac67 100644
--- a/spec/lib/api/v3/utilities/path_helper_spec.rb
+++ b/spec/lib/api/v3/utilities/path_helper_spec.rb
@@ -31,8 +31,21 @@ require 'spec_helper'
describe ::API::V3::Utilities::PathHelper do
let(:helper) { Class.new.tap { |c| c.extend(::API::V3::Utilities::PathHelper) }.api_v3_paths }
- shared_examples_for 'api v3 path' do
- it { is_expected.to match(/^\/api\/v3/) }
+ shared_examples_for 'path' do |url|
+ it 'provides the path' do
+ is_expected.to match(url)
+ end
+
+ it 'prepends the sub uri if configured' do
+ allow(OpenProject::Configuration).to receive(:rails_relative_url_root)
+ .and_return('/open_project')
+
+ is_expected.to match("/open_project#{url}")
+ end
+ end
+
+ shared_examples_for 'api v3 path' do |url|
+ it_behaves_like 'path', "/api/v3#{url}"
end
describe '#root' do
@@ -44,87 +57,67 @@ describe ::API::V3::Utilities::PathHelper do
describe '#activity' do
subject { helper.activity 1 }
- it_behaves_like 'api v3 path'
-
- it { is_expected.to match(/^\/api\/v3\/activities\/1/) }
+ it_behaves_like 'api v3 path', '/activities/1'
end
describe '#attachment' do
subject { helper.attachment 1 }
- it_behaves_like 'api v3 path'
-
- it { is_expected.to eql('/api/v3/attachments/1') }
+ it_behaves_like 'api v3 path', '/attachments/1'
end
describe '#attachment_download' do
subject { helper.attachment_download 1 }
- it { is_expected.to eql('/attachments/1') }
+ it_behaves_like 'path', '/attachments/1'
end
describe '#attachments_by_work_package' do
subject { helper.attachments_by_work_package 1 }
- it_behaves_like 'api v3 path'
-
- it { is_expected.to eql('/api/v3/work_packages/1/attachments') }
+ it_behaves_like 'api v3 path', '/work_packages/1/attachments'
end
describe '#available_assignees' do
subject { helper.available_assignees 42 }
- it_behaves_like 'api v3 path'
-
- it { is_expected.to match(/^\/api\/v3\/projects\/42\/available_assignees/) }
+ it_behaves_like 'api v3 path', '/projects/42/available_assignees'
end
describe '#available_responsibles' do
subject { helper.available_responsibles 42 }
- it_behaves_like 'api v3 path'
-
- it { is_expected.to match(/^\/api\/v3\/projects\/42\/available_responsibles/) }
+ it_behaves_like 'api v3 path', '/projects/42/available_responsibles'
end
describe '#available_watchers' do
subject { helper.available_watchers 42 }
- it_behaves_like 'api v3 path'
-
- it { is_expected.to match(/^\/api\/v3\/work_packages\/42\/available_watchers/) }
+ it_behaves_like 'api v3 path', '/work_packages/42/available_watchers'
end
describe '#categories' do
subject { helper.categories 42 }
- it_behaves_like 'api v3 path'
-
- it { is_expected.to match(/^\/api\/v3\/projects\/42\/categories/) }
+ it_behaves_like 'api v3 path', '/projects/42/categories'
end
describe '#category' do
subject { helper.category 42 }
- it_behaves_like 'api v3 path'
-
- it { is_expected.to match(/^\/api\/v3\/categories\/42/) }
+ it_behaves_like 'api v3 path', '/categories/42'
end
describe '#configuration' do
subject { helper.configuration }
- it_behaves_like 'api v3 path'
-
- it { is_expected.to eql('/api/v3/configuration') }
+ it_behaves_like 'api v3 path', '/configuration'
end
describe '#create_work_package_form' do
subject { helper.create_work_package_form 42 }
- it_behaves_like 'api v3 path'
-
- it { is_expected.to eql('/api/v3/projects/42/work_packages/form') }
+ it_behaves_like 'api v3 path', '/projects/42/work_packages/form'
end
describe '#render_markup' do
@@ -134,9 +127,7 @@ describe ::API::V3::Utilities::PathHelper do
allow(Setting).to receive(:text_formatting).and_return('by-the-settings')
end
- it_behaves_like 'api v3 path'
-
- it { is_expected.to eql('/api/v3/render/super_fancy?context=link-ish') }
+ it_behaves_like 'api v3 path', '/render/super_fancy?context=link-ish'
context 'no link given' do
subject { helper.render_markup(format: 'super_fancy') }
@@ -165,17 +156,13 @@ describe ::API::V3::Utilities::PathHelper do
describe '#priorities' do
subject { helper.priorities }
- it_behaves_like 'api v3 path'
-
- it { is_expected.to match(/^\/api\/v3\/priorities/) }
+ it_behaves_like 'api v3 path', '/priorities'
end
describe '#priority' do
subject { helper.priority 1 }
- it_behaves_like 'api v3 path'
-
- it { is_expected.to match(/^\/api\/v3\/priorities\/1/) }
+ it_behaves_like 'api v3 path', '/priorities/1'
end
end
@@ -183,59 +170,53 @@ describe ::API::V3::Utilities::PathHelper do
describe '#projects' do
subject { helper.projects }
- it_behaves_like 'api v3 path'
-
- it { is_expected.to match(/^\/api\/v3\/projects/) }
+ it_behaves_like 'api v3 path', '/projects'
end
describe '#project' do
subject { helper.project 1 }
- it_behaves_like 'api v3 path'
-
- it { is_expected.to match(/^\/api\/v3\/projects\/1/) }
+ it_behaves_like 'api v3 path', '/projects/1'
end
end
describe '#query' do
subject { helper.query 1 }
- it_behaves_like 'api v3 path'
-
- it { is_expected.to match(/^\/api\/v3\/queries\/1/) }
+ it_behaves_like 'api v3 path', '/queries/1'
end
describe '#query_star' do
subject { helper.query_star 1 }
- it_behaves_like 'api v3 path'
-
- it { is_expected.to eql('/api/v3/queries/1/star') }
+ it_behaves_like 'api v3 path', '/queries/1/star'
end
describe '#query_unstar' do
subject { helper.query_unstar 1 }
- it_behaves_like 'api v3 path'
-
- it { is_expected.to eql('/api/v3/queries/1/unstar') }
+ it_behaves_like 'api v3 path', '/queries/1/unstar'
end
describe 'relations paths' do
describe '#relation' do
subject { helper.relation 1 }
- it_behaves_like 'api v3 path'
-
- it { is_expected.to match(/^\/api\/v3\/relations/) }
+ it_behaves_like 'api v3 path', '/relations'
end
describe '#relation' do
subject { helper.relation 1 }
- it_behaves_like 'api v3 path'
+ it_behaves_like 'api v3 path', '/relations/1'
+ end
+ end
- it { is_expected.to match(/^\/api\/v3\/relations\/1/) }
+ describe 'revisions paths' do
+ describe '#revision' do
+ subject { helper.revision 1 }
+
+ it_behaves_like 'api v3 path', '/revisions/1'
end
end
@@ -243,9 +224,7 @@ describe ::API::V3::Utilities::PathHelper do
describe '#work_package_schema' do
subject { helper.work_package_schema 1, 2 }
- it_behaves_like 'api v3 path'
-
- it { is_expected.to match(/^\/api\/v3\/work_packages\/schemas\/1-2/) }
+ it_behaves_like 'api v3 path', '/work_packages/schemas/1-2'
end
end
@@ -253,17 +232,13 @@ describe ::API::V3::Utilities::PathHelper do
describe '#statuses' do
subject { helper.statuses }
- it_behaves_like 'api v3 path'
-
- it { is_expected.to match(/^\/api\/v3\/statuses/) }
+ it_behaves_like 'api v3 path', '/statuses'
end
describe '#status' do
subject { helper.status 1 }
- it_behaves_like 'api v3 path'
-
- it { is_expected.to match(/^\/api\/v3\/statuses\/1/) }
+ it_behaves_like 'api v3 path', '/statuses/1'
end
end
@@ -271,9 +246,7 @@ describe ::API::V3::Utilities::PathHelper do
describe '#string_object' do
subject { helper.string_object 'foo' }
- it_behaves_like 'api v3 path'
-
- it { is_expected.to eql('/api/v3/string_objects?value=foo') }
+ it_behaves_like 'api v3 path', '/string_objects?value=foo'
it 'escapes correctly' do
value = 'foo/bar baz'
@@ -284,9 +257,7 @@ describe ::API::V3::Utilities::PathHelper do
describe '#status' do
subject { helper.status 1 }
- it_behaves_like 'api v3 path'
-
- it { is_expected.to match(/^\/api\/v3\/statuses\/1/) }
+ it_behaves_like 'api v3 path', '/statuses/1'
end
end
@@ -294,133 +265,105 @@ describe ::API::V3::Utilities::PathHelper do
describe '#types' do
subject { helper.types }
- it_behaves_like 'api v3 path'
-
- it { is_expected.to eql('/api/v3/types') }
+ it_behaves_like 'api v3 path', '/types'
end
describe '#types_by_project' do
subject { helper.types_by_project 12 }
- it_behaves_like 'api v3 path'
-
- it { is_expected.to eql('/api/v3/projects/12/types') }
+ it_behaves_like 'api v3 path', '/projects/12/types'
end
describe '#type' do
subject { helper.type 1 }
- it_behaves_like 'api v3 path'
-
- it { is_expected.to eql('/api/v3/types/1') }
+ it_behaves_like 'api v3 path', '/types/1'
end
end
describe '#user' do
subject { helper.user 1 }
- it_behaves_like 'api v3 path'
-
- it { is_expected.to match(/^\/api\/v3\/users\/1/) }
+ it_behaves_like 'api v3 path', '/users/1'
end
describe '#version' do
subject { helper.version 42 }
- it_behaves_like 'api v3 path'
-
- it { is_expected.to match(/^\/api\/v3\/versions\/42/) }
+ it_behaves_like 'api v3 path', '/versions/42'
end
describe '#versions_by_project' do
subject { helper.versions_by_project 42 }
- it_behaves_like 'api v3 path'
-
- it { is_expected.to match(/^\/api\/v3\/projects\/42\/versions/) }
+ it_behaves_like 'api v3 path', '/projects/42/versions'
end
describe '#projects_by_version' do
subject { helper.projects_by_version 42 }
- it_behaves_like 'api v3 path'
-
- it { is_expected.to match(/^\/api\/v3\/versions\/42\/projects/) }
+ it_behaves_like 'api v3 path', '/versions/42/projects'
end
describe '#work_packages_by_project' do
subject { helper.work_packages_by_project 42 }
- it_behaves_like 'api v3 path'
-
- it { is_expected.to match(/^\/api\/v3\/projects\/42\/work_packages/) }
+ it_behaves_like 'api v3 path', '/projects/42/work_packages'
end
describe 'work packages paths' do
- shared_examples_for 'api v3 work packages path' do
- it { is_expected.to match(/^\/api\/v3\/work_packages/) }
- end
-
describe '#work_packages' do
subject { helper.work_packages }
- it_behaves_like 'api v3 work packages path'
+ it_behaves_like 'api v3 path', '/work_packages'
end
describe '#work_package' do
subject { helper.work_package 1 }
- it_behaves_like 'api v3 work packages path'
-
- it { is_expected.to match(/^\/api\/v3\/work_packages\/1/) }
+ it_behaves_like 'api v3 path', '/work_packages/1'
end
describe '#work_package_activities' do
subject { helper.work_package_activities 42 }
- it_behaves_like 'api v3 work packages path'
-
- it { is_expected.to match(/^\/api\/v3\/work_packages\/42\/activities/) }
+ it_behaves_like 'api v3 path', '/work_packages/42/activities'
end
describe '#work_package_relations' do
subject { helper.work_package_relations 42 }
- it_behaves_like 'api v3 work packages path'
-
- it { is_expected.to match(/^\/api\/v3\/work_packages\/42\/relations/) }
+ it_behaves_like 'api v3 path', '/work_packages/42/relations'
end
describe '#work_package_relation' do
subject { helper.work_package_relation 1, 42 }
- it_behaves_like 'api v3 work packages path'
+ it_behaves_like 'api v3 path', '/work_packages/42/relations/1'
+ end
- it { is_expected.to match(/^\/api\/v3\/work_packages\/42\/relations\/1/) }
+ describe '#work_package_revisions' do
+ subject { helper.work_package_revisions 42 }
+
+ it_behaves_like 'api v3 path', '/work_packages/42/revisions'
end
describe '#work_package_form' do
subject { helper.work_package_form 1 }
- it_behaves_like 'api v3 work packages path'
-
- it { is_expected.to match(/^\/api\/v3\/work_packages\/1\/form/) }
+ it_behaves_like 'api v3 path', '/work_packages/1/form'
end
describe '#work_package_watchers' do
subject { helper.work_package_watchers 1 }
- it_behaves_like 'api v3 work packages path'
-
- it { is_expected.to match(/^\/api\/v3\/work_packages\/1\/watchers/) }
+ it_behaves_like 'api v3 path', '/work_packages/1/watchers'
end
describe '#watcher' do
subject { helper.watcher 1, 42 }
- it_behaves_like 'api v3 work packages path'
-
- it { is_expected.to match(/^\/api\/v3\/work_packages\/42\/watchers\/1/) }
+ it_behaves_like 'api v3 path', '/work_packages/42/watchers/1'
end
end
end
diff --git a/spec/lib/api/v3/work_packages/work_package_representer_spec.rb b/spec/lib/api/v3/work_packages/work_package_representer_spec.rb
index e5a1c1249a1..6c8b07a0f40 100644
--- a/spec/lib/api/v3/work_packages/work_package_representer_spec.rb
+++ b/spec/lib/api/v3/work_packages/work_package_representer_spec.rb
@@ -87,7 +87,7 @@ describe ::API::V3::WorkPackages::WorkPackageRepresenter do
end
context 'no start date' do
- let(:work_package) { FactoryGirl.build(:work_package, start_date: nil) }
+ let(:work_package) { FactoryGirl.build(:work_package, id: 42, start_date: nil) }
it 'renders as null' do
is_expected.to be_json_eql(nil.to_json).at_path('startDate')
@@ -102,7 +102,7 @@ describe ::API::V3::WorkPackages::WorkPackageRepresenter do
end
context 'no due date' do
- let(:work_package) { FactoryGirl.build(:work_package, due_date: nil) }
+ let(:work_package) { FactoryGirl.build(:work_package, id: 42, due_date: nil) }
it 'renders as null' do
is_expected.to be_json_eql(nil.to_json).at_path('dueDate')
@@ -288,7 +288,7 @@ describe ::API::V3::WorkPackages::WorkPackageRepresenter do
describe 'assignee' do
context 'assignee is set' do
let(:work_package) {
- FactoryGirl.build(:work_package, assigned_to: FactoryGirl.build(:user))
+ FactoryGirl.build(:work_package, id: 42, assigned_to: FactoryGirl.build(:user))
}
it_behaves_like 'has a titled link' do
@@ -308,7 +308,7 @@ describe ::API::V3::WorkPackages::WorkPackageRepresenter do
describe 'responsible' do
context 'responsible is set' do
let(:work_package) {
- FactoryGirl.build(:work_package, responsible: FactoryGirl.build(:user))
+ FactoryGirl.build(:work_package, id: 42, responsible: FactoryGirl.build(:user))
}
it_behaves_like 'has a titled link' do
@@ -325,6 +325,26 @@ describe ::API::V3::WorkPackages::WorkPackageRepresenter do
end
end
+ describe 'revisions' do
+ context 'when the user lacks the view_changesets permission' do
+ it_behaves_like 'has no link' do
+ let(:link) { 'revisions' }
+ end
+ end
+
+ context 'when the user has the required permission' do
+ let(:revision_permissions) { [:view_changesets] }
+ let(:role) { FactoryGirl.create :role, permissions: permissions + revision_permissions }
+
+ it_behaves_like 'has an untitled link' do
+ let(:link) { 'revisions' }
+ let(:href) {
+ api_v3_paths.work_package_revisions(work_package.id)
+ }
+ end
+ end
+ end
+
describe 'version' do
let(:embedded_path) { '_embedded/version' }
let(:href_path) { '_links/version/href' }
diff --git a/spec/lib/open_project/scm/adapters/adapter_helper.rb b/spec/lib/open_project/scm/adapters/adapter_helper.rb
new file mode 100644
index 00000000000..8b7401ae986
--- /dev/null
+++ b/spec/lib/open_project/scm/adapters/adapter_helper.rb
@@ -0,0 +1,58 @@
+#-- copyright
+# OpenProject is a project management system.
+# Copyright (C) 2012-2015 the OpenProject Foundation (OPF)
+#
+# This program is free software; you can redistribute it and/or
+# modify it under the terms of the GNU General Public License version 3.
+#
+# OpenProject is a fork of ChiliProject, which is a fork of Redmine. The copyright follows:
+# Copyright (C) 2006-2013 Jean-Philippe Lang
+# Copyright (C) 2010-2013 the ChiliProject Team
+#
+# This program is free software; you can redistribute it and/or
+# modify it under the terms of the GNU General Public License
+# as published by the Free Software Foundation; either version 2
+# of the License, or (at your option) any later version.
+#
+# This program is distributed in the hope that it will be useful,
+# but WITHOUT ANY WARRANTY; without even the implied warranty of
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+# GNU General Public License for more details.
+#
+# You should have received a copy of the GNU General Public License
+# along with this program; if not, write to the Free Software
+# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
+#
+# See doc/COPYRIGHT.rdoc for more details.
+#++
+
+module Api
+ module V2
+ module Pagination
+ module PaginationSpecHelper
+ def paginating_index_action(model, scope)
+ describe '#index', type: :controller do
+ let(:params) {
+ { 'page' => '1',
+ 'page_limit' => '10',
+ 'q' => 'blubs',
+ 'format' => 'json' }
+ }
+
+ before do
+ expect(model).to receive(scope)
+ .with(params['q'])
+ .and_return(model)
+
+ get :index, params
+ end
+
+ it 'should be successful' do
+ expect(response).to be_success
+ end
+ end
+ end
+ end
+ end
+ end
+end
diff --git a/spec/lib/open_project/scm/adapters/git_adapter_spec.rb b/spec/lib/open_project/scm/adapters/git_adapter_spec.rb
new file mode 100644
index 00000000000..42601fe4fd8
--- /dev/null
+++ b/spec/lib/open_project/scm/adapters/git_adapter_spec.rb
@@ -0,0 +1,485 @@
+#-- encoding: UTF-8
+#-- copyright
+# OpenProject is a project management system.
+# Copyright (C) 2012-2015 the OpenProject Foundation (OPF)
+#
+# This program is free software; you can redistribute it and/or
+# modify it under the terms of the GNU General Public License version 3.
+#
+# OpenProject is a fork of ChiliProject, which is a fork of Redmine. The copyright follows:
+# Copyright (C) 2006-2013 Jean-Philippe Lang
+# Copyright (C) 2010-2013 the ChiliProject Team
+#
+# This program is free software; you can redistribute it and/or
+# modify it under the terms of the GNU General Public License
+# as published by the Free Software Foundation; either version 2
+# of the License, or (at your option) any later version.
+#
+# This program is distributed in the hope that it will be useful,
+# but WITHOUT ANY WARRANTY; without even the implied warranty of
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+# GNU General Public License for more details.
+#
+# You should have received a copy of the GNU General Public License
+# along with this program; if not, write to the Free Software
+# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
+#
+# See doc/COPYRIGHT.rdoc for more details.
+#++
+
+require 'spec_helper'
+
+describe OpenProject::Scm::Adapters::Git do
+ let(:url) { Rails.root.join('/tmp/does/not/exist.git').to_s }
+ let(:config) { {} }
+ let(:encoding) { nil }
+ let(:adapter) {
+ OpenProject::Scm::Adapters::Git.new(
+ url,
+ nil,
+ nil,
+ nil,
+ encoding
+ )
+ }
+
+ before do
+ allow(adapter.class).to receive(:config).and_return(config)
+ end
+
+ describe 'client information' do
+ it 'sets the Git client command' do
+ expect(adapter.client_command).to eq('git')
+ end
+
+ context 'with client command from config' do
+ let(:config) { { client_command: '/usr/local/bin/git' } }
+ it 'overrides the Git client command from config' do
+ expect(adapter.client_command).to eq('/usr/local/bin/git')
+ end
+ end
+
+ shared_examples 'correct client version' do |git_string, expected_version|
+ it 'should set the correct client version' do
+ expect(adapter)
+ .to receive(:scm_version_from_command_line)
+ .and_return(git_string)
+
+ expect(adapter.client_version).to eq(expected_version)
+ expect(adapter.client_available).to be true
+ expect(adapter.client_version_string).to eq(expected_version.join('.'))
+ end
+ end
+
+ it_behaves_like 'correct client version', "git version 1.7.3.4\n", [1, 7, 3, 4]
+ it_behaves_like 'correct client version', "1.6.1\n1.7\n1.8", [1, 6, 1]
+ it_behaves_like 'correct client version', "1.6.2\r\n1.8.1\r\n1.9.1", [1, 6, 2]
+ end
+
+ describe 'invalid repository' do
+ describe '.check_availability!' do
+ it 'should not be available' do
+ expect(Dir.exists?(url)).to be false
+ expect(adapter).not_to be_available
+ expect { adapter.check_availability! }
+ .to raise_error(OpenProject::Scm::Exceptions::ScmUnavailable)
+ end
+
+ it 'should raise a meaningful error if shell output fails' do
+ expect(adapter).to receive(:branches)
+ .and_raise OpenProject::Scm::Exceptions::CommandFailed.new('git', '')
+
+ expect { adapter.check_availability! }
+ .to raise_error(OpenProject::Scm::Exceptions::ScmUnavailable)
+ end
+ end
+ end
+
+ describe 'empty repository' do
+ include_context 'with tmpdir'
+ let(:url) { tmpdir }
+
+ before do
+ adapter.initialize_bare_git
+ end
+
+ describe '.check_availability!' do
+ it 'should be marked empty' do
+ expect { adapter.check_availability! }
+ .to raise_error(OpenProject::Scm::Exceptions::ScmEmpty)
+ end
+ end
+ end
+
+ describe 'local repository' do
+ with_git_repository do |repo_dir|
+ let(:url) { repo_dir }
+
+ it 'reads the git version' do
+ expect(adapter.client_version.length).to be >= 3
+ end
+
+ it 'is a valid repository' do
+ expect(Dir.exists?(repo_dir)).to be true
+
+ out, process = Open3.capture2e('git', '--git-dir', repo_dir, 'branch')
+ expect(process.exitstatus).to eq(0)
+ expect(out).to include('master')
+ end
+
+ it 'should be available' do
+ expect(adapter).to be_available
+ expect { adapter.check_availability! }.to_not raise_error
+ end
+
+ it 'should read tags' do
+ expect(adapter.tags).to match_array(%w[tag00.lightweight tag01.annotated])
+ end
+
+ describe '.branches' do
+ it 'should show the default branch' do
+ expect(adapter.default_branch).to eq('master')
+ end
+
+ it 'should read branches' do
+ branches = %w[latin-1-path-encoding master test-latin-1 test_branch]
+ expect(adapter.branches).to match_array(branches)
+ end
+ end
+
+ describe '.info' do
+ it 'builds the info object' do
+ info = adapter.info
+ expect(info.root_url).to eq(repo_dir)
+ expect(info.lastrev.identifier).to eq('71e5c1d3dca6304805b143b9d0e6695fb3895ea4')
+ end
+ end
+
+ describe '.lastrev' do
+ let(:felix_hex) { "Felix Sch\xC3\xA4fer" }
+
+ it 'references the last revision for empty path' do
+ lastrev = adapter.lastrev('', nil)
+ expect(lastrev.identifier).to eq('71e5c1d3dca6304805b143b9d0e6695fb3895ea4')
+ end
+
+ it 'references the last revision of the given path' do
+ lastrev = adapter.lastrev('README', nil)
+ expect(lastrev.identifier).to eq('4a07fe31bffcf2888791f3e6cbc9c4545cefe3e8')
+ expect(lastrev.author).to eq('Adam Soltys ')
+ expect(lastrev.time).to eq('2009-06-24 07:27:38 +0200')
+
+ # Even though that commit has a message, lastrev doesn't parse that deliberately
+ expect(lastrev.message).to eq('')
+ expect(lastrev.branch).to be_nil
+ expect(lastrev.paths).to be_nil
+ end
+
+ it 'references the last revision of the given path and identifier' do
+ lastrev = adapter.lastrev('README', '4f26664364207fa8b1af9f8722647ab2d4ac5d43')
+ expect(lastrev.scmid).to eq('4a07fe31bffcf2888791f3e6cbc9c4545cefe3e8')
+ expect(lastrev.identifier).to eq('4a07fe31bffcf2888791f3e6cbc9c4545cefe3e8')
+ expect(lastrev.author).to eq('Adam Soltys ')
+ expect(lastrev.time).to eq('2009-06-24 05:27:38')
+ end
+
+ it 'works with spaces in filename' do
+ lastrev = adapter.lastrev('filemane with spaces.txt',
+ 'ed5bb786bbda2dee66a2d50faf51429dbc043a7b')
+ expect(lastrev.identifier).to eq('ed5bb786bbda2dee66a2d50faf51429dbc043a7b')
+ expect(lastrev.scmid).to eq('ed5bb786bbda2dee66a2d50faf51429dbc043a7b')
+ expect(lastrev.time).to eq('2010-09-18 19:59:46')
+ end
+
+ it 'encodes strings correctly' do
+ lastrev = adapter.lastrev('filemane with spaces.txt',
+ 'ed5bb786bbda2dee66a2d50faf51429dbc043a7b')
+ expect(lastrev.author).to eq('Felix Schäfer ')
+ expect(lastrev.author).to eq("#{felix_hex} ")
+ end
+ end
+
+ describe '.revisions' do
+ it 'should retrieve all revisions' do
+ rev = adapter.revisions('', nil, nil, all: true)
+ expect(rev.length).to eq(22)
+ end
+
+ it 'should retrieve the latest revision' do
+ rev = adapter.revisions('', nil, nil, all: true)
+ expect(rev.latest.identifier).to eq('71e5c1d3dca6304805b143b9d0e6695fb3895ea4')
+ expect(rev.latest.format_identifier).to eq('71e5c1d3')
+ end
+
+ it 'should retrieve a certain revisions' do
+ rev = adapter.revisions('', '899a15d^', '899a15d')
+ expect(rev.length).to eq(1)
+ expect(rev[0].identifier).to eq('899a15dba03a3b350b89c3f537e4bbe02a03cdc9')
+ expect(rev[0].author).to eq('jsmith ')
+ end
+
+ it 'should retrieve revisions in reverse' do
+ rev = adapter.revisions('', nil, nil, all: true, reverse: true)
+ expect(rev.length).to eq(22)
+ expect(rev[0].identifier).to eq('7234cb2750b63f47bff735edc50a1c0a433c2518')
+ expect(rev[20].identifier).to eq('1ca7f5ed374f3cb31a93ae5215c2e25cc6ec5127')
+ end
+
+ it 'should retrieve revisions in a specific time frame' do
+ since = Time.gm(2010, 9, 30, 0, 0, 0)
+ rev = adapter.revisions('', nil, nil, all: true, since: since)
+ expect(rev.length).to eq(7)
+ expect(rev[0].identifier).to eq('71e5c1d3dca6304805b143b9d0e6695fb3895ea4')
+ expect(rev[1].identifier).to eq('1ca7f5ed374f3cb31a93ae5215c2e25cc6ec5127')
+ expect(rev[5].identifier).to eq('9a6f3b947d16f11b537363a60904d1b1d3bfcd2f')
+ expect(rev[6].identifier).to eq('67e7792ce20ccae2e4bb73eed09bb397819c8834')
+ end
+
+ it 'should retrieve revisions in a specific time frame in reverse' do
+ since = Time.gm(2010, 9, 30, 0, 0, 0)
+ rev = adapter.revisions('', nil, nil, all: true, since: since, reverse: true)
+ expect(rev.length).to eq(7)
+ expect(rev[0].identifier).to eq('67e7792ce20ccae2e4bb73eed09bb397819c8834')
+ expect(rev[5].identifier).to eq('1ca7f5ed374f3cb31a93ae5215c2e25cc6ec5127')
+ expect(rev[6].identifier).to eq('71e5c1d3dca6304805b143b9d0e6695fb3895ea4')
+ end
+
+ it 'should retrieve revisions by filename' do
+ rev = adapter.revisions('filemane with spaces.txt', nil, nil, all: true)
+ expect(rev.length).to eq(1)
+ expect(rev[0].identifier).to eq('ed5bb786bbda2dee66a2d50faf51429dbc043a7b')
+ end
+
+ it 'should retrieve revisions with arbitrary whitespace' do
+ file = ' filename with a leading space.txt '
+ rev = adapter.revisions(file, nil, nil, all: true)
+ expect(rev.length).to eq(1)
+ expect(rev[0].paths[0][:path]).to eq(file)
+ end
+
+ it 'should show all paths of a revision' do
+ rev = adapter.revisions('', '899a15d^', '899a15d')[0]
+ expect(rev.paths.length).to eq(3)
+ expect(rev.paths[0]).to eq(action: 'M', path: 'README')
+ expect(rev.paths[1]).to eq(action: 'A', path: 'images/edit.png')
+ expect(rev.paths[2]).to eq(action: 'A', path: 'sources/welcome_controller.rb')
+ end
+ end
+
+ describe '.entries' do
+ shared_examples 'retrieve entries' do
+ it 'should retrieve entries from an identifier' do
+ entries = adapter.entries('', '83ca5fd')
+ expect(entries.length).to eq(9)
+
+ expect(entries[0].name).to eq('images')
+ expect(entries[0].kind).to eq('dir')
+ expect(entries[0].size).to be_nil
+ expect(entries[0]).to be_dir
+ expect(entries[0]).not_to be_file
+
+ expect(entries[3]).to be_file
+ expect(entries[3].size).to eq(56)
+ expect(entries[3].name).to eq(' filename with a leading space.txt ')
+ end
+
+ it 'should have a related revision' do
+ entries = adapter.entries('', '83ca5fd')
+ rev = entries[0].lastrev
+ expect(rev.identifier).to eq('deff712f05a90d96edbd70facc47d944be5897e3')
+ expect(rev.author).to eq('Adam Soltys ')
+
+ rev = entries[3].lastrev
+ expect(rev.identifier).to eq('83ca5fd546063a3c7dc2e568ba3355661a9e2b2c')
+ expect(rev.author).to eq('Felix Schäfer ')
+ end
+
+ it 'can be retrieved by tag' do
+ entries = adapter.entries(nil, 'tag01.annotated')
+ expect(entries.length).to eq(3)
+
+ sources = entries[1]
+ expect(sources.name).to eq('sources')
+ expect(sources.path).to eq('sources')
+ expect(sources).to be_dir
+
+ readme = entries[2]
+ expect(readme.name).to eq('README')
+ expect(readme.path).to eq('README')
+ expect(readme).to be_file
+ expect(readme.size).to eq(27)
+ expect(readme.lastrev.identifier).to eq('899a15dba03a3b350b89c3f537e4bbe02a03cdc9')
+ expect(readme.lastrev.time).to eq(Time.gm(2007, 12, 14, 9, 24, 1))
+ end
+
+ it 'can be retrieved by branch' do
+ entries = adapter.entries(nil, 'test_branch')
+ expect(entries.length).to eq(4)
+ sources = entries[1]
+ expect(sources.name).to eq('sources')
+ expect(sources.path).to eq('sources')
+ expect(sources).to be_dir
+
+ readme = entries[2]
+ expect(readme.name).to eq('README')
+ expect(readme.path).to eq('README')
+ expect(readme).to be_file
+ expect(readme.size).to eq(159)
+
+ expect(readme.lastrev.identifier).to eq('713f4944648826f558cf548222f813dabe7cbb04')
+ expect(readme.lastrev.time).to eq(Time.gm(2009, 6, 19, 4, 37, 23))
+ end
+ end
+
+ describe 'encoding' do
+ let (:char1_hex) { "\xc3\x9c".force_encoding('UTF-8') }
+
+ context 'with default encoding' do
+ it_behaves_like 'retrieve entries'
+
+ it 'can retrieve directories containing entries encoded in latin-1' do
+ entries = adapter.entries('latin-1-dir', '64f1f3e8')
+ f1 = entries[1]
+
+ expect(f1.name).to eq("test-\xDC-2.txt")
+ expect(f1.path).to eq("latin-1-dir/test-\xDC-2.txt")
+ expect(f1).to be_file
+ end
+
+ it 'cannot retrieve files with latin-1 encoding in their path' do
+ entries = adapter.entries('latin-1-dir', '64f1f3e8')
+ latin1_path = entries[1].path
+
+ expect { adapter.entries(latin1_path, '1ca7f5ed') }
+ .to raise_error(OpenProject::Scm::Exceptions::CommandFailed)
+ end
+ end
+
+ context 'with latin-1 encoding' do
+ let (:encoding) { 'ISO-8859-1' }
+
+ it_behaves_like 'retrieve entries'
+
+ it 'can be retrieved with latin-1 encoding' do
+ entries = adapter.entries('latin-1-dir', '64f1f3e8')
+ expect(entries.length).to eq(3)
+ f1 = entries[1]
+
+ expect(f1.name).to eq("test-#{char1_hex}-2.txt")
+ expect(f1.path).to eq("latin-1-dir/test-#{char1_hex}-2.txt")
+ expect(f1).to be_file
+ end
+
+ it 'can be retrieved with latin-1 directories' do
+ entries = adapter.entries("latin-1-dir/test-#{char1_hex}-subdir",
+ '1ca7f5ed')
+ expect(entries.length).to eq(3)
+ f1 = entries[1]
+
+ expect(f1).to be_file
+ expect(f1.name).to eq("test-#{char1_hex}-2.txt")
+ expect(f1.path).to eq("latin-1-dir/test-#{char1_hex}-subdir/test-#{char1_hex}-2.txt")
+ end
+ end
+ end
+ end
+
+ describe '.annotate' do
+ it 'should annotate a regular file' do
+ annotate = adapter.annotate('sources/watchers_controller.rb')
+ expect(annotate).to be_kind_of(OpenProject::Scm::Adapters::Annotate)
+ expect(annotate.lines.length).to eq(41)
+ expect(annotate.lines[4].strip).to eq('# This program is free software; '\
+ 'you can redistribute it and/or')
+ expect(annotate.revisions[4].identifier).to eq('7234cb2750b63f47bff735edc50a1c0a433c2518')
+ expect(annotate.revisions[4].author).to eq('jsmith')
+ end
+
+ it 'should annotate moved file' do
+ annotate = adapter.annotate('renamed_test.txt')
+ expect(annotate.lines.length).to eq(2)
+ expect(annotate.content).to eq("This is a test\nLet's pretend I'm adding a new feature!")
+ expect(annotate.lines).to match_array(['This is a test',
+ "Let's pretend I'm adding a new feature!"])
+
+ expect(annotate.revisions.length).to eq(2)
+ expect(annotate.revisions[0].identifier).to eq('fba357b886984ee71185ad2065e65fc0417d9b92')
+ expect(annotate.revisions[1].identifier).to eq('7e61ac704deecde634b51e59daa8110435dcb3da')
+ end
+
+ it 'should annotate with identifier' do
+ annotate = adapter.annotate('README', 'HEAD~10')
+ expect(annotate.lines.length).to eq(1)
+ expect(annotate.empty?).to be false
+ expect(annotate.content).to eq("Mercurial test repository\r")
+ expect(annotate.revisions.length).to eq(1)
+ expect(annotate.revisions[0].identifier).to eq('899a15dba03a3b350b89c3f537e4bbe02a03cdc9')
+ expect(annotate.revisions[0].author).to eq('jsmith')
+ end
+
+ it 'should raise for an invalid path' do
+ expect { adapter.annotate('does_not_exist.txt') }
+ .to raise_error(OpenProject::Scm::Exceptions::CommandFailed)
+
+ expect { adapter.annotate('/path/outside/repository') }
+ .to raise_error(OpenProject::Scm::Exceptions::CommandFailed)
+ end
+
+ it 'should return nil for binary path' do
+ expect(adapter.annotate('images/edit.png')).to be_nil
+ end
+
+ # We should rethink the output of annotated files for these formats.
+ it 'also returns nil for UTF-16 encoded file' do
+ expect(adapter.annotate('utf16.txt')).to be_nil
+ end
+ end
+
+ describe '.cat' do
+ it 'outputs the given file' do
+ out = adapter.cat('README')
+ expect(out).to include('Git test repository')
+ end
+
+ it 'raises an exception for an invalid file' do
+ expect { adapter.cat('doesnotexiss') }
+ .to raise_error(OpenProject::Scm::Exceptions::CommandFailed)
+ end
+ end
+
+ describe '.diff' do
+ it 'provides a full diff of the last commit by default' do
+ diff = adapter.diff('', 'HEAD')
+ expect(diff[0]).to eq('commit 71e5c1d3dca6304805b143b9d0e6695fb3895ea4')
+ expect(diff[1]).to eq("Author: Oliver G\xFCnther ")
+ end
+
+ it 'provides a negative diff' do
+ diff = adapter.diff('', 'HEAD~2', 'HEAD~1')
+ expect(diff.join("\n")).to include('-And this is a file')
+ end
+
+ it 'provides the complete for the given range' do
+ diff = adapter.diff('', '61b685f', '2f9c009')
+ expect(diff[1]).to eq('index 6cbd30c..b94e68e 100644')
+ expect(diff[10]).to eq('index 4eca635..9a541fe 100644')
+ end
+
+ it 'provides the selected diff for the given range' do
+ diff = adapter.diff('README', '61b685f', '2f9c009')
+ expect(diff).to eq(<<-DIFF.strip_heredoc.split("\n"))
+ diff --git a/README b/README
+ index 6cbd30c..b94e68e 100644
+ --- a/README
+ +++ b/README
+ @@ -1 +1,4 @@
+ Mercurial test repository
+ +
+ +Mercurial is a distributed version control system. Mercurial is dedicated to speed and efficiency with a sane user interface.
+ +It is written in Python.
+ DIFF
+ end
+ end
+ end
+ end
+end
diff --git a/spec/lib/open_project/scm/adapters/subversion_adapter_spec.rb b/spec/lib/open_project/scm/adapters/subversion_adapter_spec.rb
new file mode 100644
index 00000000000..c69025d5068
--- /dev/null
+++ b/spec/lib/open_project/scm/adapters/subversion_adapter_spec.rb
@@ -0,0 +1,405 @@
+#-- encoding: UTF-8
+#-- copyright
+# OpenProject is a project management system.
+# Copyright (C) 2012-2015 the OpenProject Foundation (OPF)
+#
+# This program is free software; you can redistribute it and/or
+# modify it under the terms of the GNU General Public License version 3.
+#
+# OpenProject is a fork of ChiliProject, which is a fork of Redmine. The copyright follows:
+# Copyright (C) 2006-2013 Jean-Philippe Lang
+# Copyright (C) 2010-2013 the ChiliProject Team
+#
+# This program is free software; you can redistribute it and/or
+# modify it under the terms of the GNU General Public License
+# as published by the Free Software Foundation; either version 2
+# of the License, or (at your option) any later version.
+#
+# This program is distributed in the hope that it will be useful,
+# but WITHOUT ANY WARRANTY; without even the implied warranty of
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+# GNU General Public License for more details.
+#
+# You should have received a copy of the GNU General Public License
+# along with this program; if not, write to the Free Software
+# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
+#
+# See doc/COPYRIGHT.rdoc for more details.
+#++
+
+require 'spec_helper'
+
+describe OpenProject::Scm::Adapters::Subversion do
+ let(:root_url) { '/tmp/bar.svn' }
+ let(:url) { "file://#{root_url}" }
+ let(:config) { {} }
+ let(:adapter) { OpenProject::Scm::Adapters::Subversion.new url, root_url }
+
+ before do
+ allow(adapter.class).to receive(:config).and_return(config)
+ end
+
+ describe 'client information' do
+ it 'sets the Subversion client command' do
+ expect(adapter.client_command).to eq('svn')
+ end
+
+ context 'with client command from config' do
+ let(:config) { { client_command: '/usr/local/bin/svn' } }
+ it 'overrides the Subversion client command from config' do
+ expect(adapter.client_command).to eq('/usr/local/bin/svn')
+ end
+ end
+
+ shared_examples 'correct client version' do |svn_string, expected_version|
+ it 'should set the correct client version' do
+ expect(adapter)
+ .to receive(:scm_version_from_command_line)
+ .and_return(svn_string)
+
+ expect(adapter.client_version).to eq(expected_version)
+ expect(adapter.client_available).to be true
+ expect(adapter.client_version_string).to eq(expected_version.join('.'))
+ end
+ end
+
+ it_behaves_like 'correct client version', "svn, version 1.6.13 (r1002816)\n", [1, 6, 13]
+ it_behaves_like 'correct client version', "svn, versione 1.6.13 (r1002816)\n", [1, 6, 13]
+ it_behaves_like 'correct client version', "1.6.1\n1.7\n1.8", [1, 6, 1]
+ it_behaves_like 'correct client version', "1.6.2\r\n1.8.1\r\n1.9.1", [1, 6, 2]
+ end
+
+ describe 'invalid repository' do
+ describe '.check_availability!' do
+ it 'should not be available' do
+ expect(Dir.exists?(url)).to be false
+ expect(adapter).not_to be_available
+ expect { adapter.check_availability! }
+ .to raise_error(OpenProject::Scm::Exceptions::ScmUnavailable)
+ end
+
+ it 'should raise a meaningful error if shell output fails' do
+ error_string = <<-ERR.strip_heredoc
+ svn: E215004: Authentication failed and interactive prompting is disabled; see the --force-interactive option
+ svn: E215004: Unable to connect to a repository at URL 'file:///tmp/bar.svn'
+ svn: E215004: No more credentials or we tried too many times.
+ Authentication failed
+ ERR
+
+ allow(adapter).to receive(:popen3)
+ .and_yield(StringIO.new(''), StringIO.new(error_string))
+
+ expect { adapter.check_availability! }
+ .to raise_error(OpenProject::Scm::Exceptions::ScmUnauthorized)
+ end
+ end
+ end
+
+ describe 'repository with authorization' do
+ let(:adapter) { OpenProject::Scm::Adapters::Subversion.new url, root_url, login, password }
+ let(:login) { 'whatever@example.org' }
+ let(:svn_cmd) { adapter.send :build_svn_cmd, ['info'] }
+
+ context 'without password' do
+ let(:password) { nil }
+
+ it 'creates the subversion command' do
+ idx = svn_cmd.index('--username')
+ expect(idx).not_to be_nil
+ expect(svn_cmd[idx + 1]).to eq(login)
+ expect(svn_cmd).not_to include('--password')
+ end
+ end
+
+ context 'with password' do
+ let(:password) { 'VG%\';rm -rf /;},Y^m\+DuE,vJP/9' }
+
+ it 'creates the subversion command' do
+ idx = svn_cmd.index('--username')
+ expect(idx).not_to be_nil
+ expect(svn_cmd[idx + 1]).to eq(login)
+
+ idx = svn_cmd.index('--password')
+ expect(idx).not_to be_nil
+ expect(svn_cmd[idx + 1])
+ .to eq("VG\\%\\'\\;rm\\ -rf\\ /\\;\\},Y\\\\^m\\\\\\+DuE,vJP/9")
+ end
+ end
+ end
+
+ describe 'empty repository' do
+ include_context 'with tmpdir'
+ let(:root_url) { tmpdir }
+
+ describe '.create_empty_svn' do
+ context 'with valid root_url' do
+ it 'should create the repository' do
+ expect(Dir.exists?(root_url)).to be true
+ expect(Dir.entries(root_url).length).to eq 2
+ expect { adapter.create_empty_svn }.not_to raise_error
+
+ expect(Dir.exists?(root_url)).to be true
+ expect(Dir.entries(root_url).length).to be >= 5
+ end
+ end
+ context 'with non-existing root_url' do
+ let(:root_url) { File.join(tmpdir, 'foo', 'bar') }
+
+ it 'should fail' do
+ expect { adapter.create_empty_svn }
+ .to raise_error(OpenProject::Scm::Exceptions::CommandFailed)
+
+ expect(Dir.exists?(root_url)).to be false
+ end
+ end
+ end
+
+ describe '.check_availability!' do
+ it 'should be marked empty' do
+ adapter.create_empty_svn
+ expect { adapter.check_availability! }
+ .to raise_error(OpenProject::Scm::Exceptions::ScmEmpty)
+ end
+ end
+ end
+
+ describe 'local repository' do
+ with_subversion_repository do |repo_dir|
+ let(:root_url) { repo_dir }
+
+ it 'reads the Subversion version' do
+ expect(adapter.client_version.length).to be >= 3
+ end
+
+ it 'is a valid repository' do
+ expect(Dir.exists?(repo_dir)).to be true
+
+ out, process = Open3.capture2e('svn', 'info', url)
+ expect(process.exitstatus).to eq(0)
+ expect(out).to include('Repository UUID')
+ end
+
+ it 'should be available' do
+ expect(adapter).to be_available
+ expect { adapter.check_availability! }.to_not raise_error
+ end
+
+ describe '.info' do
+ it 'builds the info object' do
+ info = adapter.info
+ expect(info.root_url).to eq(url)
+ expect(info.lastrev.identifier).to eq('12')
+ expect(info.lastrev.author).to eq('oliver')
+ expect(info.lastrev.time).to eq('2015-07-08T13:32:29.228572Z')
+ end
+ end
+
+ describe '.entries' do
+ it 'reads all entries from the current revision' do
+ entries = adapter.entries
+ expect(entries.length).to eq(1)
+
+ expect(entries[0].name).to eq('subversion_test')
+ expect(entries[0].path).to eq('subversion_test')
+ end
+
+ it 'contains a reference to the last revision' do
+ entries = adapter.entries
+ expect(entries.length).to eq(1)
+ lastrev = entries[0].lastrev
+
+ expect(lastrev.identifier).to eq('12')
+ expect(lastrev.author).to eq('oliver')
+ expect(lastrev.message).to eq('')
+ expect(lastrev.time).to eq('2015-07-08T13:32:29.228572Z')
+ end
+
+ it 'reads all entries from the given revision' do
+ entries = adapter.entries(nil, 1)
+ expect(entries.length).to eq(1)
+ lastrev = entries[0].lastrev
+
+ expect(lastrev.identifier).to eq('1')
+ expect(lastrev.author).to eq('jp')
+ expect(lastrev.message).to eq('')
+ expect(lastrev.time).to eq('2007-09-10T16:54:38.484000Z')
+ end
+
+ it 'reads all entries from the given path' do
+ entries = adapter.entries('subversion_test')
+ expect(entries.length).to eq(5)
+
+ expect(entries[0].name).to eq('[folder_with_brackets]')
+ expect(entries[0].path).to eq('subversion_test/[folder_with_brackets]')
+ expect(entries[0]).to be_dir
+ expect(entries[0]).not_to be_file
+ expect(entries[0].size).to be_nil
+
+ expect(entries[1].name).to eq('folder')
+ expect(entries[1].path).to eq('subversion_test/folder')
+ expect(entries[1]).to be_dir
+ expect(entries[1]).not_to be_file
+ expect(entries[1].size).to be_nil
+
+ expect(entries[4].name).to eq('textfile.txt')
+ expect(entries[4].path).to eq('subversion_test/textfile.txt')
+ expect(entries[4]).to_not be_dir
+ expect(entries[4]).to be_file
+ expect(entries[4]).not_to be_dir
+ expect(entries[4].size).to eq(756)
+ end
+
+ it 'reads all entries from the given path and revision' do
+ entries = adapter.entries('subversion_test', '2')
+ expect(entries.length).to eq(4)
+ expect(entries[0].name).to eq('folder')
+ expect(entries[0].path).to eq('subversion_test/folder')
+
+ expect(entries[1].name).to eq('.project')
+ expect(entries[1].path).to eq('subversion_test/.project')
+
+ expect(entries[2].name).to eq('helloworld.rb')
+ expect(entries[2].path).to eq('subversion_test/helloworld.rb')
+
+ expect(entries[3].name).to eq('textfile.txt')
+ expect(entries[3].path).to eq('subversion_test/textfile.txt')
+ end
+ end
+
+ describe '.properties' do
+ it 'returns an empty hash for no properties' do
+ expect(adapter.properties('')).to eq({})
+ end
+
+ it 'returns the properties when available' do
+ expect(adapter.properties('subversion_test')).to eq('svn:ignore' => "foo\nbar/\n")
+ end
+
+ it 'does not return the properties from an older revision on the same path' do
+ expect(adapter.properties('subversion_test', 11)).to eq({})
+ end
+ end
+
+ describe '.revisions' do
+ it 'returns all revisions by default' do
+ revisions = adapter.revisions
+ expect(revisions.length).to eq(12)
+
+ expect(revisions[0].author).to eq('oliver')
+ expect(revisions[0].message).to eq("Propedit\n")
+
+ revisions.each_with_index do |rev, i|
+ expect(rev.identifier).to eq((12 - i).to_s)
+ end
+ end
+
+ it 'returns revisions for a specific path' do
+ revisions = adapter.revisions('subversion_test/[folder_with_brackets]', nil, nil,
+ with_paths: true)
+
+ expect(revisions.length).to eq(1)
+ expect(revisions[0].identifier).to eq('11')
+ expect(revisions[0].format_identifier).to eq('11')
+
+ paths = revisions[0].paths
+ expect(paths.length).to eq(2)
+ expect(paths[0]).to eq(action: 'A', path: '/subversion_test/[folder_with_brackets]',
+ from_path: nil, from_revision: nil)
+ end
+
+ it 'returns revision for a specific path and revision' do
+ # Folder was added in rev 2
+ expect { adapter.revisions('subversion_test/folder', 1) }
+ .to raise_error(OpenProject::Scm::Exceptions::CommandFailed)
+
+ revisions = adapter.revisions('subversion_test/folder', 2, nil,
+ with_paths: true)
+
+ expect(revisions.length).to eq(1)
+ expect(revisions[0].identifier).to eq('2')
+
+ paths = revisions[0].paths
+ expect(paths.length).to eq(7)
+ end
+
+ it 'returns revision for a specific range' do
+ revisions = adapter.revisions('subversion_test/folder', 2, 5,
+ with_paths: true)
+ expect(revisions.length).to eq(2)
+ expect(revisions[0].identifier).to eq('2')
+ expect(revisions[0].message).to eq('Initial import.')
+ expect(revisions[1].identifier).to eq('5')
+ expect(revisions[1].message).to eq('Modified one file in the folder.')
+
+ expect(revisions[0].paths.length).to eq(7)
+ expect(revisions[1].paths.length).to eq(1)
+ end
+ end
+
+ describe '.blame' do
+ it 'blames an existing file at the given path' do
+ annotate = adapter.annotate('subversion_test/[folder_with_brackets]/README.txt')
+ expect(annotate.lines.length).to eq(2)
+ expect(annotate.revisions.length).to eq(2)
+
+ expect(annotate.revisions[0].identifier).to eq('11')
+ expect(annotate.revisions[0].author).to eq('schmidt')
+ end
+
+ it 'outputs nothing for an invalid blame target' do
+ annotate = adapter.annotate('subversion_test/[folder_with_brackets]/README.txt', 10)
+ expect(annotate.lines.length).to eq(0)
+ expect(annotate.revisions.length).to eq(0)
+ end
+ end
+
+ describe '.cat' do
+ it 'outputs the given file' do
+ out = adapter.cat('subversion_test/[folder_with_brackets]/README.txt', 11)
+ expect(out).to eq('This file should be accessible for Redmine, '\
+ "although its folder contains square\nbrackets.\n")
+ end
+
+ it 'raises an exception for an invalid file' do
+ expect { adapter.cat('subversion_test/[folder_with_brackets]/README.txt', 10) }
+ .to raise_error(OpenProject::Scm::Exceptions::CommandFailed)
+ end
+ end
+
+ describe '.diff' do
+ it 'provides a full diff against the last revision' do
+ diff = adapter.diff('', 12)
+ expect(diff.join("\n")).to include('Added: svn:ignore')
+ end
+
+ it 'provides a negative diff' do
+ diff = adapter.diff('', 11, 12)
+ expect(diff.join("\n")).to include('Deleted: svn:ignore')
+ end
+
+ it 'provides the complete for the given range' do
+ diff = adapter.diff('', 8, 6).join("\n")
+ expect(diff).to include('Index: subversion_test/folder/greeter.rb')
+ expect(diff).to include('Index: subversion_test/helloworld.c')
+ end
+
+ it 'provides the selected diff for the given range' do
+ diff = adapter.diff('subversion_test/helloworld.c', 8, 6)
+ expect(diff).to eq(<<-DIFF.strip_heredoc.split("\n"))
+ Index: helloworld.c
+ ===================================================================
+ --- helloworld.c (revision 6)
+ +++ helloworld.c (revision 8)
+ @@ -3,6 +3,5 @@
+ int main(void)
+ {
+ printf("hello, world\\n");
+ -
+ return 0;
+ }
+ DIFF
+ end
+ end
+ end
+ end
+end
diff --git a/spec/workers/mail_notification_jobs/deliver_work_package_created_job_spec.rb b/spec/lib/open_project/scm/manager_spec.rb
similarity index 69%
rename from spec/workers/mail_notification_jobs/deliver_work_package_created_job_spec.rb
rename to spec/lib/open_project/scm/manager_spec.rb
index db89b0efe7c..4942623f6d5 100644
--- a/spec/workers/mail_notification_jobs/deliver_work_package_created_job_spec.rb
+++ b/spec/lib/open_project/scm/manager_spec.rb
@@ -28,21 +28,28 @@
#++
require 'spec_helper'
-require 'workers/mail_notification_jobs/shared_examples'
-describe DeliverWorkPackageCreatedJob, type: :model do
- let(:work_package) { FactoryGirl.create :work_package, subject: mail_subject }
- let(:job) { DeliverWorkPackageCreatedJob.new user.id, work_package.id, user.id }
+describe OpenProject::Scm::Manager do
+ let(:vendor) { 'TestScm' }
+ let(:scm_class) { Class.new }
- it_behaves_like 'a mail notification job' do
- context 'with work package not found' do
- let(:mail_subject) { 'no work package found! :o' }
+ before do
+ Repository.const_set(vendor, scm_class)
+ OpenProject::Scm::Manager.add vendor
+ end
- before do
- work_package.destroy
- end
+ after do
+ Repository.send(:remove_const, vendor)
+ OpenProject::Scm::Manager.delete vendor
+ end
- it_behaves_like 'job cannot find record'
+ it 'is a valid const' do
+ expect(OpenProject::Scm::Manager.registered[vendor]).to eq(Repository::TestScm)
+ end
+
+ context 'scm is not known' do
+ it 'is not included' do
+ expect(OpenProject::Scm::Manager.registered).to_not have_key('SomeOtherScm')
end
end
end
diff --git a/spec/lib/open_project/text_formatting_spec.rb b/spec/lib/open_project/text_formatting_spec.rb
index 4f5213c32d3..e6fd3e189ed 100644
--- a/spec/lib/open_project/text_formatting_spec.rb
+++ b/spec/lib/open_project/text_formatting_spec.rb
@@ -65,7 +65,7 @@ describe OpenProject::TextFormatting do
context 'Changeset links' do
let(:repository) do
- FactoryGirl.build_stubbed :repository,
+ FactoryGirl.build_stubbed :repository_subversion,
project: project
end
let(:changeset1) do
@@ -431,7 +431,7 @@ describe OpenProject::TextFormatting do
context 'Redmine links' do
let(:repository) do
- FactoryGirl.build_stubbed :repository, project: project
+ FactoryGirl.build_stubbed :repository_subversion, project: project
end
let(:source_url) do
{ controller: 'repositories',
diff --git a/spec/lib/redmine/i18n_spec.rb b/spec/lib/redmine/i18n_spec.rb
index c2132ec9b86..e729a86a9ca 100644
--- a/spec/lib/redmine/i18n_spec.rb
+++ b/spec/lib/redmine/i18n_spec.rb
@@ -130,21 +130,24 @@ module OpenProject
end
describe 'find_language' do
- it 'is nil if language is not active' do
+ before do
allow(Setting).to receive(:available_languages).and_return([:de])
+ end
+ it 'is nil if language is not active' do
expect(find_language(:en)).to be_nil
end
- it 'is the language if it is active' do
- allow(Setting).to receive(:available_languages).and_return([:de])
+ it 'is nil if no language is given' do
+ expect(find_language('')).to be_nil
+ expect(find_language(nil)).to be_nil
+ end
+ it 'is the language if it is active' do
expect(find_language(:de)).to eql :de
end
it 'can be found by uppercase if it is active' do
- allow(Setting).to receive(:available_languages).and_return([:de])
-
expect(find_language(:DE)).to eql :de
end
end
diff --git a/spec/lib/tabular_form_builder_spec.rb b/spec/lib/tabular_form_builder_spec.rb
index 7d61a1fb51c..13bb2c697fc 100644
--- a/spec/lib/tabular_form_builder_spec.rb
+++ b/spec/lib/tabular_form_builder_spec.rb
@@ -77,6 +77,10 @@ describe TabularFormBuilder do
it_behaves_like 'wrapped in field-container by default'
it_behaves_like 'wrapped in container', 'text-field-container'
+ before do
+ allow(Setting).to receive(:available_languages).and_return([:en])
+ end
+
it 'should output element' do
expect(output).to be_html_eql(%{
no special behaviour required
+
+ describe 'Journal 1' do
+ include_context 'updated until Journal 1'
+
+ it_behaves_like 'enqueues a regular notification'
+ end
+
+ describe 'Journal 2' do
+ include_context 'updated until Journal 2'
+
+ it_behaves_like 'enqueues a regular notification'
+ end
+
+ describe 'Journal 3' do
+ include_context 'updated until Journal 3'
+
+ it_behaves_like 'enqueues a regular notification'
+ end
+ end
+
+ context 'journal 3 created after timeout of 1, but inside of timeout for 2' do
+ describe 'Journal 3' do
+ include_context 'updated until Journal 3'
+
+ before do
+ journal_2.update_attribute(:created_at, journal_1.created_at + (timeout / 2))
+ journal_3.update_attribute(:created_at, journal_1.created_at + timeout + 5.seconds)
+ end
+
+ it 'immediately delivers a mail on behalf of Journal 1' do
+ expect(Delayed::Job).to receive(:enqueue)
+ .with(
+ an_instance_of(DeliverWorkPackageNotificationJob))
+ call_listener
+ end
+
+ it 'also enqueues a regular mail' do
+ expect(Delayed::Job).to receive(:enqueue)
+ .with(
+ an_instance_of(EnqueueWorkPackageNotificationJob),
+ run_at: anything)
+ call_listener
+ end
+ end
+ end
+
+ context 'journal 3 created after timeout of 1 and 2' do
+ # This is a normal case again, ensuring Journal 3 takes no responsiblity when not neccessary.
+
+ describe 'Journal 3' do
+ include_context 'updated until Journal 3'
+
+ before do
+ journal_2.update_attribute(:created_at, journal_1.created_at + (timeout / 2))
+ journal_3.update_attribute(:created_at, journal_2.created_at + timeout + 5.seconds)
+ end
+
+ it_behaves_like 'enqueues a regular notification'
+ end
+ end
+
+ context 'two subsequent changes after timeout of another journal' do
+ # This is a normal case again, because handling edge cases makes us miss on the normal cases
+
+ before do
+ work_package.journals.first.update_attribute(:created_at, (timeout + 5.seconds).ago)
+ work_package.reload
+
+ expect(work_package.update_by!(author, { done_ratio: 50 })).to be_truthy
+ work_package.reload
+ expect(work_package.update_by!(author, { done_ratio: 60 })).to be_truthy
+ work_package.reload
+ end
+
+ it_behaves_like 'enqueues a regular notification'
+ end
+ end
+end
+
+describe 'initialization' do
+ it 'subscribes the listener' do
+ expect(JournalNotificationMailer).to receive(:distinguish_journals)
+ FactoryGirl.create(:work_package)
+ end
+end
diff --git a/spec/models/news_spec.rb b/spec/models/news_spec.rb
index 772fad8aa93..3d793b09996 100644
--- a/spec/models/news_spec.rb
+++ b/spec/models/news_spec.rb
@@ -99,6 +99,8 @@ describe News, type: :model do
user = FactoryGirl.create(:user)
become_member_with_permissions(project, user)
+ # reload
+ project.members(true)
with_settings notified_events: ['news_added'] do
FactoryGirl.create(:news, project: project)
diff --git a/spec/models/project_spec.rb b/spec/models/project_spec.rb
index 900b0f4cfa6..b298b92fee8 100644
--- a/spec/models/project_spec.rb
+++ b/spec/models/project_spec.rb
@@ -248,4 +248,59 @@ describe Project, type: :model do
it_behaves_like 'respecting group assignment settings'
end
end
+
+ describe 'countable required_project_storage' do
+ describe '#required_storage' do
+ it 'counts collections' do
+ expect(project).to receive(:count_for).with(:required_project_storage).and_call_original
+ storage_hash = project.required_storage
+ expect(storage_hash).to eq('attributes.attachments' => 0, total: 0)
+ end
+
+ it 'counts only once before caching' do
+ expect(project)
+ .to receive(:count_for).with(:required_project_storage)
+ .once
+ .and_return([{ 'whatever' => 123, total: 1234 }, 'my label'])
+
+ project.required_storage
+ project.required_storage
+ end
+ end
+
+ describe '#total_projects_size' do
+ let(:projects) { FactoryGirl.build_list(:project, 3) }
+ before do
+ projects.each(&:save!)
+ allow(Project).to receive(:all).and_return(projects)
+
+ allow(projects[0]).to receive(:count_for).and_return(
+ [{ 'attributes.attachments' => 23543, total: 23543 },
+ 'unused project label']
+ )
+ allow(projects[1]).to receive(:count_for).and_return(
+ [{ 'attributes.attachments' => 2, label_repository: 2412345, total: 2412347 },
+ 'unused project label']
+ )
+ Rails.cache.clear('projects/total_projects_size')
+ end
+
+ it 'counts required_storage on all projects' do
+ expect(Project.all.length).to eq(3)
+ expect(Project.total_projects_size).to eq(2435890)
+ end
+
+ it 'counts required_storage only once' do
+ expect(project).not_to receive(:count_for).with(:required_project_storage)
+ projects.each do |p|
+ expect(p).not_to receive(:count_for)
+ .with(:required_project_storage)
+ .once
+ .and_call_original
+ end
+ Project.total_projects_size
+ Project.total_projects_size
+ end
+ end
+ end
end
diff --git a/spec/models/repository/filesystem_spec.rb b/spec/models/repository/filesystem_spec.rb
deleted file mode 100644
index 9fca58f1f13..00000000000
--- a/spec/models/repository/filesystem_spec.rb
+++ /dev/null
@@ -1,158 +0,0 @@
-#-- copyright
-# OpenProject is a project management system.
-# Copyright (C) 2012-2015 the OpenProject Foundation (OPF)
-#
-# This program is free software; you can redistribute it and/or
-# modify it under the terms of the GNU General Public License version 3.
-#
-# OpenProject is a fork of ChiliProject, which is a fork of Redmine. The copyright follows:
-# Copyright (C) 2006-2013 Jean-Philippe Lang
-# Copyright (C) 2010-2013 the ChiliProject Team
-#
-# This program is free software; you can redistribute it and/or
-# modify it under the terms of the GNU General Public License
-# as published by the Free Software Foundation; either version 2
-# of the License, or (at your option) any later version.
-#
-# This program is distributed in the hope that it will be useful,
-# but WITHOUT ANY WARRANTY; without even the implied warranty of
-# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
-# GNU General Public License for more details.
-#
-# You should have received a copy of the GNU General Public License
-# along with this program; if not, write to the Free Software
-# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
-#
-# See doc/COPYRIGHT.rdoc for more details.
-#++
-
-require 'spec_helper'
-
-describe Repository::Filesystem, type: :model do
- before do
- allow(Setting).to receive(:enabled_scm).and_return(['Filesystem'])
- end
-
- let(:instance) { described_class.new }
-
- def mock_dirs_exist(input, output)
- allow(Dir).to receive(:glob).with(input).and_return(output)
- allow(Dir).to receive(:exists?).with(input).and_return(true)
- end
-
- describe '.configured?' do
- subject { described_class.configured? }
-
- context 'configuration contains directories' do
- before do
- allow(OpenProject::Configuration)
- .to receive(:[])
- .with('scm_filesystem_path_whitelist')
- .and_return(['/dir'])
- end
-
- it 'is true' do
- is_expected.to be_truthy
- end
- end
-
- context 'configuration does not contain directories' do
- before do
- allow(OpenProject::Configuration)
- .to receive(:[])
- .with('scm_filesystem_path_whitelist')
- .and_return([])
- end
-
- it 'is false' do
- is_expected.to be_falsey
- end
- end
- end
-
- describe '#valid?' do
- let(:desired_url) { 'something' }
- let(:whitelisted_urls) { 'another_thing' }
-
- let(:valid_args) do
- { url: desired_url,
- path_encoding: 'US-ASCII' }
- end
- let(:expected_url_whitelist_error_message) do
- [I18n.t('activerecord.errors.models.repository.not_whitelisted')]
- end
- let(:expected_url_not_directory_error_message) do
- [I18n.t('activerecord.errors.models.repository.no_directory')]
- end
-
- before do
- mock_dirs_exist(desired_url, ['/this/will/match'])
- mock_dirs_exist(whitelisted_urls, ['/this/will/match'])
-
- allow(OpenProject::Configuration).to receive(:[])
- .with('scm_filesystem_path_whitelist')
- .and_return(whitelisted_urls)
-
- instance.attributes = valid_args
- end
-
- subject { instance }
-
- it 'is valid' do
- is_expected.to be_valid
- end
-
- context 'url not whitelisted' do
- before do
- mock_dirs_exist(desired_url, ['/desired/dir'])
- mock_dirs_exist(whitelisted_urls, ['/desired',
- '/desired/*',
- '/desired/di',
- '/desired/dir/1',
- '*'])
- end
-
- it 'is invalid' do
- instance.attributes = valid_args
-
- is_expected.to be_invalid
- expect(subject.errors[:url]).to eql(expected_url_whitelist_error_message)
- end
- end
-
- context 'url is not a directory' do
- before do
- allow(Dir).to receive(:exists?).with(desired_url).and_return(false)
- end
-
- it 'is invalid' do
- is_expected.to be_invalid
- expect(subject.errors[:url]).to eql(expected_url_not_directory_error_message)
- end
- end
-
- context 'url does not exist' do
- before do
- mock_dirs_exist(desired_url, [])
- end
-
- it 'is invalid' do
- is_expected.to be_invalid
- expect(subject.errors[:url]).to eql(expected_url_whitelist_error_message)
- end
- end
-
- context 'nothing is whitelisted' do
- before do
- mock_dirs_exist(whitelisted_urls, [])
- end
-
- it 'is invalid' do
- instance.attributes = valid_args
-
- is_expected.to be_invalid
- expect(subject.errors[:url]).to eql(expected_url_whitelist_error_message)
- end
- end
- end
-end
diff --git a/spec/models/repository/git_spec.rb b/spec/models/repository/git_spec.rb
new file mode 100644
index 00000000000..361e208400f
--- /dev/null
+++ b/spec/models/repository/git_spec.rb
@@ -0,0 +1,376 @@
+#-- copyright
+# OpenProject is a project management system.
+# Copyright (C) 2012-2015 the OpenProject Foundation (OPF)
+#
+# This program is free software; you can redistribute it and/or
+# modify it under the terms of the GNU General Public License version 3.
+#
+# OpenProject is a fork of ChiliProject, which is a fork of Redmine. The copyright follows:
+# Copyright (C) 2006-2013 Jean-Philippe Lang
+# Copyright (C) 2010-2013 the ChiliProject Team
+#
+# This program is free software; you can redistribute it and/or
+# modify it under the terms of the GNU General Public License
+# as published by the Free Software Foundation; either version 2
+# of the License, or (at your option) any later version.
+#
+# This program is distributed in the hope that it will be useful,
+# but WITHOUT ANY WARRANTY; without even the implied warranty of
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+# GNU General Public License for more details.
+#
+# You should have received a copy of the GNU General Public License
+# along with this program; if not, write to the Free Software
+# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
+#
+# See doc/COPYRIGHT.rdoc for more details.
+#++
+
+require 'spec_helper'
+
+describe Repository::Git, type: :model do
+ let(:encoding) { 'UTF-8' }
+ let(:instance) { FactoryGirl.build(:repository_git, path_encoding: encoding) }
+ let(:adapter) { instance.scm }
+ let(:config) { {} }
+
+ before do
+ allow(Setting).to receive(:enabled_scm).and_return(['Git'])
+ allow(instance).to receive(:scm).and_return(adapter)
+ allow(adapter.class).to receive(:config).and_return(config)
+ end
+
+ describe 'available types' do
+ it 'allow local by default' do
+ expect(instance.class.available_types).to eq([:local])
+ end
+
+ context 'with disabled typed' do
+ let(:config) { { disabled_types: [:local, :managed] } }
+
+ it 'does not have any types' do
+ expect(instance.class.available_types).to be_empty
+ end
+ end
+ end
+
+ describe 'managed git' do
+ let(:managed_path) { '/tmp/managed_git' }
+ it 'is not manageable unless configured explicitly' do
+ expect(instance.manageable?).to be false
+ end
+
+ context 'with managed config' do
+ let(:config) { { manages: managed_path } }
+ let(:project) { FactoryGirl.build :project }
+ let(:identifier) { project.identifier + '.git' }
+
+ it 'is manageable' do
+ expect(instance.manageable?).to be true
+ expect(instance.class.available_types).to eq([:local, :managed])
+ end
+
+ context 'with disabled managed typed' do
+ let(:config) { { disabled_types: [:managed] } }
+
+ it 'is no longer manageable' do
+ expect(instance.class.available_types).to eq([:local])
+ expect(instance.manageable?).to be false
+ end
+ end
+
+ context 'and associated project' do
+ before do
+ instance.project = project
+ end
+
+ it 'outputs valid managed paths' do
+ expect(instance.repository_identifier).to eq(identifier)
+ path = File.join(managed_path, identifier)
+ expect(instance.managed_repository_path).to eq(path)
+ expect(instance.managed_repository_url).to eq(path)
+ end
+ end
+
+ context 'and associated project with parent' do
+ let(:parent) { FactoryGirl.build :project }
+ let(:project) { FactoryGirl.build :project, parent: parent }
+
+ before do
+ instance.project = project
+ end
+
+ it 'outputs the correct hierarchy path' do
+ expect(instance.managed_repository_path)
+ .to eq(File.join(managed_path, identifier))
+ end
+ end
+ end
+ end
+
+ describe 'with an actual repository' do
+ with_git_repository do |repo_dir|
+ let(:url) { repo_dir }
+ let(:instance) {
+ FactoryGirl.create(:repository_git,
+ path_encoding: encoding,
+ url: url,
+ root_url: url)
+ }
+
+ before do
+ instance.fetch_changesets
+ instance.reload
+ end
+
+ it 'should be available' do
+ expect(instance.scm).to be_available
+ end
+
+ it 'should fetch changesets from scratch' do
+ expect(instance.changesets.count).to eq(22)
+ expect(instance.changes.count).to eq(34)
+
+ commit = instance.changesets.reorder('committed_on ASC').first
+ expect(commit.comments).to eq("Initial import.\nThe repository contains 3 files.")
+ expect(commit.committer).to eq('jsmith ')
+ # assert_equal User.find_by_login('jsmith'), commit.user
+ # TODO: add a commit with commit time <> author time to the test repository
+ expect(commit.committed_on).to eq('2007-12-14 09:22:52')
+ expect(commit.commit_date).to eq('2007-12-14'.to_date)
+ expect(commit.revision).to eq('7234cb2750b63f47bff735edc50a1c0a433c2518')
+ expect(commit.scmid).to eq('7234cb2750b63f47bff735edc50a1c0a433c2518')
+ expect(commit.changes.count).to eq(3)
+
+ change = commit.changes.sort_by(&:path).first
+ expect(change.path).to eq('README')
+ expect(change.action).to eq('A')
+ end
+
+ it 'should fetch changesets incremental' do
+ # Remove the 3 latest changesets
+ instance.changesets.find(:all, order: 'committed_on DESC', limit: 8).each(&:destroy)
+ instance.reload
+ expect(instance.changesets.count).to eq(14)
+
+ rev_a_commit = instance.changesets.order('committed_on DESC').first
+ expect(rev_a_commit.revision).to eq('ed5bb786bbda2dee66a2d50faf51429dbc043a7b')
+ expect(rev_a_commit.scmid).to eq('ed5bb786bbda2dee66a2d50faf51429dbc043a7b')
+ # Mon Jul 5 22:34:26 2010 +0200
+ committed_on = Time.gm(2010, 9, 18, 19, 59, 46)
+ expect(rev_a_commit.committed_on).to eq(committed_on)
+ expect(instance.latest_changeset.committed_on).to eq(committed_on)
+
+ instance.fetch_changesets
+ expect(instance.changesets.count).to eq(22)
+ end
+
+ describe '.latest_changesets' do
+ it 'should fetch changesets with limits' do
+ changesets = instance.latest_changesets('', nil, 2)
+ expect(changesets.size).to eq(2)
+ end
+
+ it 'should fetch changesets with paths' do
+ changesets = instance.latest_changesets('images', nil)
+ expect(changesets.map(&:revision))
+ .to eq(['deff712f05a90d96edbd70facc47d944be5897e3',
+ '899a15dba03a3b350b89c3f537e4bbe02a03cdc9',
+ '7234cb2750b63f47bff735edc50a1c0a433c2518'])
+
+ changesets = instance.latest_changesets('README', nil)
+ expect(changesets.map(&:revision))
+ .to eq(['32ae898b720c2f7eec2723d5bdd558b4cb2d3ddf',
+ '4a07fe31bffcf2888791f3e6cbc9c4545cefe3e8',
+ '713f4944648826f558cf548222f813dabe7cbb04',
+ '61b685fbe55ab05b5ac68402d5720c1a6ac973d1',
+ '899a15dba03a3b350b89c3f537e4bbe02a03cdc9',
+ '7234cb2750b63f47bff735edc50a1c0a433c2518'])
+ end
+
+ it 'should fetch changesets with path, revision and limit' do
+ changesets = instance.latest_changesets('images', '899a15dba')
+ expect(changesets.map(&:revision))
+ .to eq(['899a15dba03a3b350b89c3f537e4bbe02a03cdc9',
+ '7234cb2750b63f47bff735edc50a1c0a433c2518'])
+
+ changesets = instance.latest_changesets('images', '899a15dba', 1)
+ expect(changesets.map(&:revision))
+ .to eq(['899a15dba03a3b350b89c3f537e4bbe02a03cdc9'])
+
+ changesets = instance.latest_changesets('README', '899a15dba')
+ expect(changesets.map(&:revision))
+ .to eq(['899a15dba03a3b350b89c3f537e4bbe02a03cdc9',
+ '7234cb2750b63f47bff735edc50a1c0a433c2518'])
+
+ changesets = instance.latest_changesets('README', '899a15dba', 1)
+ expect(changesets.map(&:revision))
+ .to eq(['899a15dba03a3b350b89c3f537e4bbe02a03cdc9'])
+ end
+
+ it 'should fetch changesets with tag' do
+ changesets = instance.latest_changesets('images', 'tag01.annotated')
+ expect(changesets.map(&:revision))
+ .to eq(['899a15dba03a3b350b89c3f537e4bbe02a03cdc9',
+ '7234cb2750b63f47bff735edc50a1c0a433c2518'])
+
+ changesets = instance.latest_changesets('README', '899a15dba', 1)
+ expect(changesets.map(&:revision))
+ .to eq(['899a15dba03a3b350b89c3f537e4bbe02a03cdc9'])
+
+ changesets = instance.latest_changesets('README', 'tag01.annotated')
+ expect(changesets.map(&:revision))
+ .to eq(['899a15dba03a3b350b89c3f537e4bbe02a03cdc9',
+ '7234cb2750b63f47bff735edc50a1c0a433c2518'])
+
+ changesets = instance.latest_changesets('README', 'tag01.annotated', 1)
+ expect(changesets.map(&:revision))
+ .to eq(['899a15dba03a3b350b89c3f537e4bbe02a03cdc9'])
+ end
+
+ it 'should fetch changesets with path, branch, and limit' do
+ changesets = instance.latest_changesets('images', 'test_branch')
+ expect(changesets.map(&:revision))
+ .to eq(['899a15dba03a3b350b89c3f537e4bbe02a03cdc9',
+ '7234cb2750b63f47bff735edc50a1c0a433c2518'])
+
+ changesets = instance.latest_changesets('images', 'test_branch', 1)
+ expect(changesets.map(&:revision))
+ .to eq(['899a15dba03a3b350b89c3f537e4bbe02a03cdc9'])
+
+ changesets = instance.latest_changesets('README', 'test_branch')
+ expect(changesets.map(&:revision))
+ .to eq(['713f4944648826f558cf548222f813dabe7cbb04',
+ '61b685fbe55ab05b5ac68402d5720c1a6ac973d1',
+ '899a15dba03a3b350b89c3f537e4bbe02a03cdc9',
+ '7234cb2750b63f47bff735edc50a1c0a433c2518'])
+
+ changesets = instance.latest_changesets('README', 'test_branch', 2)
+ expect(changesets.map(&:revision))
+ .to eq(['713f4944648826f558cf548222f813dabe7cbb04',
+ '61b685fbe55ab05b5ac68402d5720c1a6ac973d1'])
+ end
+ end
+
+ it 'should find changeset by name' do
+ ['7234cb2750b63f47bff735edc50a1c0a433c2518', '7234cb2750b'].each do |r|
+ expect(instance.find_changeset_by_name(r).revision)
+ .to eq('7234cb2750b63f47bff735edc50a1c0a433c2518')
+ end
+ end
+
+ it 'should find changeset by empty name' do
+ ['', ' ', nil].each do |r|
+ expect(instance.find_changeset_by_name(r)).to be_nil
+ end
+ end
+
+ it 'should assign scmid to identifier' do
+ c = instance.changesets.where(revision: '7234cb2750b63f47bff735edc50a1c0a433c2518').first
+ expect(c.scmid).to eq(c.identifier)
+ end
+
+ it 'should format identifier' do
+ c = instance.changesets.where(revision: '7234cb2750b63f47bff735edc50a1c0a433c2518').first
+ expect(c.format_identifier).to eq('7234cb27')
+ end
+
+ it 'should find previous changeset' do
+ %w|1ca7f5ed374f3cb31a93ae5215c2e25cc6ec5127 1ca7f5ed|.each do |r1|
+ changeset = instance.find_changeset_by_name(r1)
+ %w|64f1f3e89ad1cb57976ff0ad99a107012ba3481d 64f1f3e89ad1|.each do |r2|
+ expect(instance.find_changeset_by_name(r2)).to eq(changeset.previous)
+ end
+ end
+ end
+
+ it 'should return nil when no previous changeset' do
+ %w|7234cb2750b63f47bff735edc50a1c0a433c2518 7234cb2|.each do |r1|
+ changeset = instance.find_changeset_by_name(r1)
+ expect(changeset.previous).to be_nil
+ end
+ end
+
+ it 'should find next changeset' do
+ %w|64f1f3e89ad1cb57976ff0ad99a107012ba3481d 64f1f3e89ad1|.each do |r2|
+ changeset = instance.find_changeset_by_name(r2)
+ %w|1ca7f5ed374f3cb31a93ae5215c2e25cc6ec5127 1ca7f5ed|.each do |r1|
+ expect(instance.find_changeset_by_name(r1)).to eq(changeset.next)
+ end
+ end
+ end
+
+ it 'should next nil' do
+ %w|71e5c1d3dca6304805b143b9d0e6695fb3895ea4 71e5c1d3|.each do |r1|
+ changeset = instance.find_changeset_by_name(r1)
+ expect(changeset.next).to be_nil
+ end
+ end
+
+ context 'with an admin browsing activity' do
+ let(:user) { FactoryGirl.create(:admin) }
+ let(:project) { FactoryGirl.create(:project) }
+
+ def find_events(user, options = {})
+ fetcher = Redmine::Activity::Fetcher.new(user, options)
+ fetcher.scope = ['changesets']
+ fetcher.events(Date.today - 30, Date.today + 1)
+ end
+
+ it 'should activities' do
+ Changeset.create(repository: instance,
+ committed_on: Time.now,
+ revision: 'abc7234cb2750b63f47bff735edc50a1c0a433c2',
+ scmid: 'abc7234cb2750b63f47bff735edc50a1c0a433c2',
+ comments: 'test')
+
+ event = find_events(user).first
+ assert event.event_title.include?('abc7234c:')
+ assert event.event_path =~ /\?rev=abc7234cb2750b63f47bff735edc50a1c0a433c2$/
+ end
+ end
+
+ describe 'encoding' do
+ let(:felix_hex) { "Felix Sch\xC3\xA4fer" }
+
+ it 'should display UTF-8' do
+ c = instance.changesets.where(revision: 'ed5bb786bbda2dee66a2d50faf51429dbc043a7b').first
+ expect(c.committer).to eq("#{felix_hex} ")
+ expect(c.committer).to eq('Felix Schäfer ')
+ end
+
+ context 'with latin-1 encoding' do
+ let (:encoding) { 'ISO-8859-1' }
+ let (:char1_hex) { "\xc3\x9c".force_encoding('UTF-8') }
+
+ it 'should latest changesets latin 1 dir' do
+ instance.fetch_changesets
+ instance.reload
+ changesets = instance.latest_changesets(
+ "latin-1-dir/test-#{char1_hex}-subdir", '1ca7f5ed')
+ expect(changesets.map(&:revision))
+ .to eq(['1ca7f5ed374f3cb31a93ae5215c2e25cc6ec5127'])
+ end
+
+ it 'should browse changesets' do
+ changesets = instance.latest_changesets(
+ "latin-1-dir/test-#{char1_hex}-2.txt", '64f1f3e89')
+ expect(changesets.map(&:revision))
+ .to eq(['64f1f3e89ad1cb57976ff0ad99a107012ba3481d',
+ '4fc55c43bf3d3dc2efb66145365ddc17639ce81e',
+ ])
+
+ changesets = instance.latest_changesets(
+ "latin-1-dir/test-#{char1_hex}-2.txt", '64f1f3e89', 1)
+ expect(changesets.map(&:revision))
+ .to eq(['64f1f3e89ad1cb57976ff0ad99a107012ba3481d'])
+ end
+ end
+ end
+
+ it_behaves_like 'is a countable repository' do
+ let(:repository) { instance }
+ end
+ end
+ end
+end
diff --git a/spec/models/repository/subversion_spec.rb b/spec/models/repository/subversion_spec.rb
new file mode 100644
index 00000000000..82de6da3fba
--- /dev/null
+++ b/spec/models/repository/subversion_spec.rb
@@ -0,0 +1,301 @@
+#-- copyright
+# OpenProject is a project management system.
+# Copyright (C) 2012-2015 the OpenProject Foundation (OPF)
+#
+# This program is free software; you can redistribute it and/or
+# modify it under the terms of the GNU General Public License version 3.
+#
+# OpenProject is a fork of ChiliProject, which is a fork of Redmine. The copyright follows:
+# Copyright (C) 2006-2013 Jean-Philippe Lang
+# Copyright (C) 2010-2013 the ChiliProject Team
+#
+# This program is free software; you can redistribute it and/or
+# modify it under the terms of the GNU General Public License
+# as published by the Free Software Foundation; either version 2
+# of the License, or (at your option) any later version.
+#
+# This program is distributed in the hope that it will be useful,
+# but WITHOUT ANY WARRANTY; without even the implied warranty of
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+# GNU General Public License for more details.
+#
+# You should have received a copy of the GNU General Public License
+# along with this program; if not, write to the Free Software
+# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
+#
+# See doc/COPYRIGHT.rdoc for more details.
+#++
+
+require 'spec_helper'
+
+describe Repository::Subversion, type: :model do
+ let(:instance) { FactoryGirl.build(:repository_subversion) }
+ let(:adapter) { instance.scm }
+ let(:config) { {} }
+
+ before do
+ allow(Setting).to receive(:enabled_scm).and_return(['Subversion'])
+ allow(instance).to receive(:scm).and_return(adapter)
+ allow(instance.class).to receive(:scm_config).and_return(config)
+ end
+
+ describe 'default Subversion' do
+ it 'is not manageable' do
+ expect(instance.manageable?).to be false
+ end
+
+ it 'has one available type' do
+ expect(instance.class.available_types).to eq [:existing]
+ end
+
+ context 'with disabled typed' do
+ let(:config) { { disabled_types: [:existing, :managed] } }
+
+ it 'does not have any types' do
+ expect(instance.class.available_types).to be_empty
+ end
+ end
+ end
+
+ describe 'managed Subversion' do
+ let(:managed_path) { '/tmp/managed_svn' }
+ it 'is not manageable unless configured explicitly' do
+ expect(instance.manageable?).to be false
+ end
+
+ context 'with managed config' do
+ let(:config) { { manages: managed_path } }
+ let(:project) { FactoryGirl.build :project }
+
+ it 'is manageable' do
+ expect(instance.manageable?).to be true
+ expect(instance.class.available_types).to eq([:existing, :managed])
+ end
+
+ context 'with disabled managed typed' do
+ let(:config) { { disabled_types: [:managed] } }
+
+ it 'is no longer manageable' do
+ expect(instance.class.available_types).to eq([:existing])
+ expect(instance.manageable?).to be false
+ end
+ end
+
+ context 'and associated project' do
+ before do
+ instance.project = project
+ end
+
+ it 'outputs valid managed paths' do
+ path = File.join(managed_path, project.identifier)
+ expect(instance.managed_repository_path).to eq(path)
+ expect(instance.managed_repository_url).to eq("file://#{path}")
+ end
+ end
+
+ context 'and associated project with parent' do
+ let(:parent) { FactoryGirl.build :project }
+ let(:project) { FactoryGirl.build :project, parent: parent }
+
+ before do
+ instance.project = project
+ end
+
+ it 'outputs the correct hierarchy path' do
+ expect(instance.managed_repository_path)
+ .to eq(File.join(managed_path, project.identifier))
+ end
+ end
+ end
+ end
+
+ describe 'with a remote repository' do
+ let(:instance) {
+ FactoryGirl.build(:repository_subversion,
+ url: 'https://somewhere.example.org/svn/foo'
+ )
+ }
+
+ it_behaves_like 'is not a countable repository' do
+ let(:repository) { instance }
+ end
+ end
+
+ describe 'with an actual repository' do
+ with_subversion_repository do |repo_dir|
+ let(:url) { "file://#{repo_dir}" }
+ let(:instance) { FactoryGirl.create(:repository_subversion, url: url, root_url: url) }
+
+ it 'should be available' do
+ expect(instance.scm).to be_available
+ end
+
+ it 'should fetch changesets from scratch' do
+ instance.fetch_changesets
+ instance.reload
+
+ expect(instance.changesets.count).to eq(12)
+ expect(instance.changes.count).to eq(21)
+ expect(instance.changesets.find_by_revision('1').comments).to eq('Initial import.')
+ end
+
+ it 'should fetch changesets incremental' do
+ instance.fetch_changesets
+
+ # Remove changesets with revision > 5
+ instance.changesets.find(:all).each do |c| c.destroy if c.revision.to_i > 5 end
+ instance.reload
+ expect(instance.changesets.count).to eq(5)
+
+ instance.fetch_changesets
+ expect(instance.changesets.count).to eq(12)
+ end
+
+ it 'should latest changesets' do
+ instance.fetch_changesets
+
+ # with limit
+ changesets = instance.latest_changesets('', nil, 2)
+ assert_equal 2, changesets.size
+ assert_equal instance.latest_changesets('', nil).slice(0, 2), changesets
+
+ # with path
+ changesets = instance.latest_changesets('subversion_test/folder', nil)
+ expect(changesets.map(&:revision)).to eq %w[10 9 7 6 5 2]
+
+ # with path and revision
+ changesets = instance.latest_changesets('subversion_test/folder', 8)
+ expect(changesets.map(&:revision)).to eq %w[7 6 5 2]
+ end
+
+ it 'should directory listing with square brackets in path' do
+ instance.fetch_changesets
+ instance.reload
+
+ entries = instance.entries('subversion_test/[folder_with_brackets]')
+ expect(entries).to_not be_nil
+ expect(entries.size).to eq(1)
+ expect(entries.first.name).to eq('README.txt')
+ end
+
+ context 'with square brackets in base' do
+ let(:url) { "file://#{repo_dir}/subversion_test/[folder_with_brackets]" }
+
+ it 'should directory listing with square brackets in base' do
+ instance.fetch_changesets
+ instance.reload
+
+ expect(instance.changesets.count).to eq(1)
+ expect(instance.changes.count).to eq(2)
+
+ entries = instance.entries('')
+ expect(entries).to_not be_nil
+ expect(entries.size).to eq(1)
+ expect(entries.first.name).to eq('README.txt')
+ end
+ end
+
+ it 'should show the identifier' do
+ instance.fetch_changesets
+ instance.reload
+ c = instance.changesets.find_by_revision('1')
+ expect(c.revision).to eq(c.identifier)
+ end
+
+ it 'should find changeset by empty name' do
+ instance.fetch_changesets
+ instance.reload
+ ['', ' ', nil].each do |r|
+ expect(instance.find_changeset_by_name(r)).to be_nil
+ end
+ end
+
+ it 'should identifier nine digit' do
+ c = Changeset.new(repository: instance, committed_on: Time.now,
+ revision: '123456789', comments: 'test')
+ expect(c.identifier).to eq(c.revision)
+ end
+
+ it 'should format identifier' do
+ instance.fetch_changesets
+ instance.reload
+ c = instance.changesets.find_by_revision('1')
+ expect(c.format_identifier).to eq(c.revision)
+ end
+
+ it 'should format identifier nine digit' do
+ c = Changeset.new(repository: instance, committed_on: Time.now,
+ revision: '123456789', comments: 'test')
+ expect(c.format_identifier).to eq(c.revision)
+ end
+
+ it 'should log encoding ignore setting' do
+ with_settings commit_logs_encoding: 'windows-1252' do
+ s1 = "\xC2\x80"
+ s2 = "\xc3\x82\xc2\x80"
+ if s1.respond_to?(:force_encoding)
+ s1.force_encoding('ISO-8859-1')
+ s2.force_encoding('UTF-8')
+ assert_equal s1.encode('UTF-8'), s2
+ end
+ c = Changeset.new(repository: instance,
+ comments: s2,
+ revision: '123',
+ committed_on: Time.now)
+ expect(c.save).to be true
+ expect(c.comments).to eq(s2)
+ end
+ end
+
+ it 'should load previous and next changeset' do
+ instance.fetch_changesets
+ instance.reload
+ changeset2 = instance.find_changeset_by_name('2')
+ changeset3 = instance.find_changeset_by_name('3')
+ expect(changeset3.previous).to eq(changeset2)
+ expect(changeset2.next).to eq(changeset3)
+ end
+
+ it 'should return nil for no previous or next changeset' do
+ instance.fetch_changesets
+ instance.reload
+ changeset = instance.find_changeset_by_name('1')
+ expect(changeset.previous).to be_nil
+
+ changeset = instance.find_changeset_by_name('12')
+ expect(changeset.next).to be_nil
+ end
+
+ context 'with an admin browsing activity' do
+ let(:user) { FactoryGirl.create(:admin) }
+ let(:project) { FactoryGirl.create(:project) }
+
+ def find_events(user, options = {})
+ fetcher = Redmine::Activity::Fetcher.new(user, options)
+ fetcher.scope = ['changesets']
+ fetcher.events(Date.today - 30, Date.today + 1)
+ end
+
+ it 'should find events' do
+ Changeset.create(repository: instance, committed_on: Time.now,
+ revision: '1', comments: 'test')
+ event = find_events(user).first
+ expect(event.event_title).to include('1:')
+ expect(event.event_path).to match(/\?rev=1$/)
+ end
+
+ it 'should find events with larger numbers' do
+ Changeset.create(repository: instance, committed_on: Time.now,
+ revision: '123456789', comments: 'test')
+ event = find_events(user).first
+ expect(event.event_title).to include('123456789:')
+ expect(event.event_path).to match(/\?rev=123456789$/)
+ end
+ end
+
+ it_behaves_like 'is a countable repository' do
+ let(:repository) { instance }
+ end
+ end
+ end
+end
diff --git a/spec/models/user_deletion_spec.rb b/spec/models/user_deletion_spec.rb
index fb4c44f4573..8060ae14ffb 100644
--- a/spec/models/user_deletion_spec.rb
+++ b/spec/models/user_deletion_spec.rb
@@ -95,13 +95,13 @@ describe User, 'deletion', type: :model do
it { expect(associated_instance.journals.first.user).to eq(user2) }
it 'should update first journal changes' do
associations.each do |association|
- expect(associated_instance.journals.first.changed_data[association_key association].last).to eq(user2.id)
+ expect(associated_instance.journals.first.details[association_key association].last).to eq(user2.id)
end
end
it { expect(associated_instance.journals.last.user).to eq(substitute_user) }
it 'should update second journal changes' do
associations.each do |association|
- expect(associated_instance.journals.last.changed_data[association_key association].last).to eq(substitute_user.id)
+ expect(associated_instance.journals.last.details[association_key association].last).to eq(substitute_user.id)
end
end
end
@@ -157,14 +157,14 @@ describe User, 'deletion', type: :model do
it { expect(associated_instance.journals.first.user).to eq(substitute_user) }
it 'should update the first journal' do
associations.each do |association|
- expect(associated_instance.journals.first.changed_data[association_key association].last).to eq(substitute_user.id)
+ expect(associated_instance.journals.first.details[association_key association].last).to eq(substitute_user.id)
end
end
it { expect(associated_instance.journals.last.user).to eq(user2) }
it 'should update the last journal' do
associations.each do |association|
- expect(associated_instance.journals.last.changed_data[association_key association].first).to eq(substitute_user.id)
- expect(associated_instance.journals.last.changed_data[association_key association].last).to eq(user2.id)
+ expect(associated_instance.journals.last.details[association_key association].first).to eq(substitute_user.id)
+ expect(associated_instance.journals.last.details[association_key association].last).to eq(user2.id)
end
end
end
@@ -233,13 +233,13 @@ describe User, 'deletion', type: :model do
it { expect(associated_instance.journals.first.user).to eq(user2) }
it 'should update first journal changes' do
associations.each do |association|
- expect(associated_instance.journals.first.changed_data[association_key association].last).to eq(user2.id)
+ expect(associated_instance.journals.first.details[association_key association].last).to eq(user2.id)
end
end
it { expect(associated_instance.journals.last.user).to eq(substitute_user) }
it 'should update second journal changes' do
associations.each do |association|
- expect(associated_instance.journals.last.changed_data[association_key association].last).to eq(substitute_user.id)
+ expect(associated_instance.journals.last.details[association_key association].last).to eq(substitute_user.id)
end
end
end
@@ -397,7 +397,7 @@ describe User, 'deletion', type: :model do
end
describe 'WHEN the user has created a changeset' do
- with_created_filesystem_repository do
+ with_virtual_subversion_repository do
let(:associated_instance) do
FactoryGirl.build(:changeset,
repository_id: repository.id,
@@ -412,7 +412,7 @@ describe User, 'deletion', type: :model do
end
describe 'WHEN the user has updated a changeset' do
- with_created_filesystem_repository do
+ with_virtual_subversion_repository do
let(:associated_instance) do
FactoryGirl.build(:changeset,
repository_id: repository.id,
@@ -443,11 +443,11 @@ describe User, 'deletion', type: :model do
end
it { expect(associated_instance.journals.first.user).to eq(user2) }
it 'should update first journal changes' do
- expect(associated_instance.journals.first.changed_data[:user_id].last).to eq(user2.id)
+ expect(associated_instance.journals.first.details[:user_id].last).to eq(user2.id)
end
it { expect(associated_instance.journals.last.user).to eq(substitute_user) }
it 'should update second journal changes' do
- expect(associated_instance.journals.last.changed_data[:user_id].last).to eq(substitute_user.id)
+ expect(associated_instance.journals.last.details[:user_id].last).to eq(substitute_user.id)
end
end
diff --git a/spec/models/work_package/work_package_action_mailer_spec.rb b/spec/models/work_package/work_package_action_mailer_spec.rb
index 5e9f9d56f0f..4d0cad3c2e9 100644
--- a/spec/models/work_package/work_package_action_mailer_spec.rb
+++ b/spec/models/work_package/work_package_action_mailer_spec.rb
@@ -31,20 +31,23 @@ require 'spec_helper'
describe WorkPackage, type: :model do
describe ActionMailer::Base do
let(:user_1) {
- FactoryGirl.create(:user,
- mail: 'dlopper@somenet.foo')
+ FactoryGirl.build(:user,
+ mail: 'dlopper@somenet.foo',
+ member_in_project: project)
}
let(:user_2) {
- FactoryGirl.create(:user,
- mail: 'jsmith@somenet.foo')
+ FactoryGirl.build(:user,
+ mail: 'jsmith@somenet.foo',
+ member_in_project: project)
}
- let(:work_package) { FactoryGirl.build(:work_package) }
+ let(:project) { FactoryGirl.create(:project) }
+ let(:work_package) { FactoryGirl.build(:work_package, project: project) }
before do
ActionMailer::Base.deliveries.clear
- allow(work_package).to receive(:recipients).and_return([user_1.mail])
- allow(work_package).to receive(:watcher_recipients).and_return([user_2.mail])
+ allow(work_package).to receive(:recipients).and_return([user_1])
+ allow(work_package).to receive(:watcher_recipients).and_return([user_2])
work_package.save
end
@@ -73,7 +76,7 @@ describe WorkPackage, type: :model do
before do
ActionMailer::Base.deliveries.clear
- WorkPackageObserver.instance.send_notification = false
+ JournalManager.send_notification = false
work_package.save!
end
@@ -91,7 +94,7 @@ describe WorkPackage, type: :model do
subject { work_package.recipients }
- it { is_expected.to include(user_1.mail) }
+ it { is_expected.to include(user_1) }
end
end
end
diff --git a/spec/models/work_package/work_package_acts_as_journalized_spec.rb b/spec/models/work_package/work_package_acts_as_journalized_spec.rb
index 546a6925ff0..b9e67bb1cd4 100644
--- a/spec/models/work_package/work_package_acts_as_journalized_spec.rb
+++ b/spec/models/work_package/work_package_acts_as_journalized_spec.rb
@@ -162,7 +162,7 @@ describe WorkPackage, type: :model do
end
context 'last created journal' do
- subject { work_package.journals.last.changed_data }
+ subject { work_package.journals.last.details }
it 'contains all changes' do
[:subject, :description, :type_id, :status_id, :priority_id,
@@ -232,7 +232,7 @@ describe WorkPackage, type: :model do
end
context 'new attachment' do
- subject { work_package.journals.last.changed_data }
+ subject { work_package.journals.last.details }
it { is_expected.to have_key attachment_id }
@@ -254,7 +254,7 @@ describe WorkPackage, type: :model do
context 'attachment removed' do
before do work_package.attachments.delete(attachment) end
- subject { work_package.journals.last.changed_data }
+ subject { work_package.journals.last.details }
it { is_expected.to have_key attachment_id }
@@ -285,7 +285,7 @@ describe WorkPackage, type: :model do
context 'new custom value' do
include_context 'work package with custom value'
- subject { work_package.journals.last.changed_data }
+ subject { work_package.journals.last.details }
it { is_expected.to have_key custom_field_id }
@@ -305,7 +305,7 @@ describe WorkPackage, type: :model do
work_package.save!
end
- subject { work_package.journals.last.changed_data }
+ subject { work_package.journals.last.details }
it { is_expected.to have_key custom_field_id }
@@ -340,7 +340,7 @@ describe WorkPackage, type: :model do
work_package.save!
end
- subject { work_package.journals.last.changed_data }
+ subject { work_package.journals.last.details }
it { is_expected.to have_key custom_field_id }
diff --git a/spec/models/work_package/work_package_copy_spec.rb b/spec/models/work_package/work_package_copy_spec.rb
deleted file mode 100644
index 80fda7ce2d0..00000000000
--- a/spec/models/work_package/work_package_copy_spec.rb
+++ /dev/null
@@ -1,360 +0,0 @@
-#-- copyright
-# OpenProject is a project management system.
-# Copyright (C) 2012-2015 the OpenProject Foundation (OPF)
-#
-# This program is free software; you can redistribute it and/or
-# modify it under the terms of the GNU General Public License version 3.
-#
-# OpenProject is a fork of ChiliProject, which is a fork of Redmine. The copyright follows:
-# Copyright (C) 2006-2013 Jean-Philippe Lang
-# Copyright (C) 2010-2013 the ChiliProject Team
-#
-# This program is free software; you can redistribute it and/or
-# modify it under the terms of the GNU General Public License
-# as published by the Free Software Foundation; either version 2
-# of the License, or (at your option) any later version.
-#
-# This program is distributed in the hope that it will be useful,
-# but WITHOUT ANY WARRANTY; without even the implied warranty of
-# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
-# GNU General Public License for more details.
-#
-# You should have received a copy of the GNU General Public License
-# along with this program; if not, write to the Free Software
-# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
-#
-# See doc/COPYRIGHT.rdoc for more details.
-#++
-
-require 'spec_helper'
-
-describe WorkPackage, type: :model do
- describe '#copy' do
- let(:user) { FactoryGirl.create(:user) }
- let(:custom_field) { FactoryGirl.create(:work_package_custom_field) }
- let(:source_type) {
- FactoryGirl.create(:type,
- custom_fields: [custom_field])
- }
- let(:source_project) {
- FactoryGirl.create(:project,
- types: [source_type])
- }
- let(:work_package) {
- FactoryGirl.create(:work_package,
- project: source_project,
- type: source_type,
- author: user)
- }
- let(:custom_value) {
- FactoryGirl.create(:work_package_custom_value,
- custom_field: custom_field,
- customized: work_package,
- value: false)
- }
-
- shared_examples_for 'copied work package' do
- subject { copy.id }
-
- it { is_expected.not_to eq(work_package.id) }
- end
-
- describe 'to the same project' do
- let(:copy) { work_package.move_to_project(source_project, nil, copy: true) }
-
- it_behaves_like 'copied work package'
-
- context 'project' do
- subject { copy.project }
-
- it { is_expected.to eq(source_project) }
- end
- end
-
- describe 'to a different project' do
- let(:target_type) { FactoryGirl.create(:type) }
- let(:target_project) {
- FactoryGirl.create(:project,
- types: [target_type])
- }
- let(:copy) { work_package.move_to_project(target_project, target_type, copy: true) }
-
- it_behaves_like 'copied work package'
-
- context 'project' do
- subject { copy.project_id }
-
- it { is_expected.to eq(target_project.id) }
- end
-
- context 'type' do
- subject { copy.type_id }
-
- it { is_expected.to eq(target_type.id) }
- end
-
- context 'custom_fields' do
- before do custom_value end
-
- subject { copy.custom_value_for(custom_field.id) }
-
- it { is_expected.to be_nil }
- end
-
- describe '#attributes' do
- let(:copy) {
- work_package.move_to_project(target_project,
- target_type,
- copy: true,
- attributes: attributes)
- }
-
- context 'assigned_to' do
- let(:target_user) { FactoryGirl.create(:user) }
- let(:target_project_member) {
- FactoryGirl.create(:member,
- project: target_project,
- principal: target_user,
- roles: [FactoryGirl.create(:role)])
- }
- let(:attributes) { { assigned_to_id: target_user.id } }
-
- before do target_project_member end
-
- it_behaves_like 'copied work package'
-
- subject { copy.assigned_to_id }
-
- it { is_expected.to eq(target_user.id) }
- end
-
- context 'status' do
- let(:target_status) { FactoryGirl.create(:status) }
- let(:attributes) { { status_id: target_status.id } }
-
- it_behaves_like 'copied work package'
-
- subject { copy.status_id }
-
- it { is_expected.to eq(target_status.id) }
- end
-
- context 'date' do
- let(:target_date) { Date.today + 14 }
-
- context 'start' do
- let(:attributes) { { start_date: target_date } }
-
- it_behaves_like 'copied work package'
-
- subject { copy.start_date }
-
- it { is_expected.to eq(target_date) }
- end
-
- context 'end' do
- let(:attributes) { { due_date: target_date } }
-
- it_behaves_like 'copied work package'
-
- subject { copy.due_date }
-
- it { is_expected.to eq(target_date) }
- end
- end
- end
-
- describe 'private project' do
- let(:role) {
- FactoryGirl.create(:role,
- permissions: [:view_work_packages])
- }
- let(:target_project) {
- FactoryGirl.create(:project,
- is_public: false,
- types: [target_type])
- }
- let(:source_project_member) {
- FactoryGirl.create(:member,
- project: source_project,
- principal: user,
- roles: [role])
- }
-
- before do
- source_project_member
- allow(User).to receive(:current).and_return user
- end
-
- it_behaves_like 'copied work package'
-
- context 'pre-condition' do
- subject { work_package.recipients }
-
- it { is_expected.to include(work_package.author.mail) }
- end
-
- subject { copy.recipients }
-
- it { is_expected.not_to include(copy.author.mail) }
- end
-
- describe 'with children' do
- let(:target_project) { FactoryGirl.create(:project, types: [source_type]) }
- let(:copy) { child.reload.move_to_project(target_project) }
- let!(:child) {
- FactoryGirl.create(:work_package, parent: work_package, project: source_project)
- }
- let!(:grandchild) {
- FactoryGirl.create(:work_package, parent: child, project: source_project)
- }
-
- context 'cross project relations deactivated' do
- before do
- allow(Setting).to receive(:cross_project_work_package_relations?).and_return(false)
- end
-
- it { expect(copy).to be_falsy }
-
- it { expect(child.reload.project).to eql(source_project) }
-
- describe 'grandchild' do
- before do copy end
-
- it { expect(grandchild.reload.project).to eql(source_project) }
- end
- end
-
- context 'cross project relations activated' do
- before do
- allow(Setting).to receive(:cross_project_work_package_relations?).and_return(true)
- end
-
- it { expect(copy).to be_truthy }
-
- it { expect(copy.project).to eql(target_project) }
-
- describe 'grandchild' do
- before do copy end
-
- it { expect(grandchild.reload.project).to eql(target_project) }
- end
- end
- end
- end
- end
-
- shared_context 'project with required custom field' do
- before do
- project.work_package_custom_fields << custom_field
- type.custom_fields << custom_field
-
- source.save
- end
- end
-
- before do
- def self.change_custom_field_value(work_package, value)
- work_package.custom_field_values = { custom_field.id => value } unless value.nil?
- work_package.save
- end
- end
-
- let(:type) { FactoryGirl.create(:type_standard) }
- let(:project) { FactoryGirl.create(:project, types: [type]) }
- let(:custom_field) {
- FactoryGirl.create(:work_package_custom_field,
- name: 'Database',
- field_format: 'list',
- possible_values: ['MySQL', 'PostgreSQL', 'Oracle'],
- is_required: true)
- }
-
- describe '#copy_from' do
- include_context 'project with required custom field'
-
- let(:source) { FactoryGirl.build(:work_package) }
- let(:sink) { FactoryGirl.build(:work_package) }
-
- before do
- source.project_id = project.id
- change_custom_field_value(source, 'MySQL')
- end
-
- shared_examples_for 'work package copy' do
- context 'subject' do
- subject { sink.subject }
-
- it { is_expected.to eq(source.subject) }
- end
-
- context 'type' do
- subject { sink.type }
-
- it { is_expected.to eq(source.type) }
- end
-
- context 'status' do
- subject { sink.status }
-
- it { is_expected.to eq(source.status) }
- end
-
- context 'project' do
- subject { sink.project_id }
-
- it { is_expected.to eq(project_id) }
- end
-
- context 'watchers' do
- subject { sink.watchers.map(&:user_id) }
-
- it do
- is_expected.to match_array(source.watchers.map(&:user_id))
- sink.watchers.each { |w| expect(w).to be_valid }
- end
- end
- end
-
- shared_examples_for 'work package copy with custom field' do
- it_behaves_like 'work package copy'
-
- context 'custom_field' do
- subject { sink.custom_value_for(custom_field.id).value }
-
- it { is_expected.to eq('MySQL') }
- end
- end
-
- context 'with project' do
- let(:project_id) { source.project_id }
-
- describe 'should copy project' do
- before do sink.copy_from(source) end
-
- it_behaves_like 'work package copy with custom field'
- end
-
- describe 'should not copy excluded project' do
- let(:project_id) { sink.project_id }
-
- before do sink.copy_from(source, exclude: [:project_id]) end
-
- it_behaves_like 'work package copy'
- end
-
- describe 'should copy over watchers' do
- let(:project_id) { sink.project_id }
- let(:stub_user) { FactoryGirl.create(:user, member_in_project: project) }
-
- before do
- source.watchers.build(user: stub_user, watchable: source)
-
- sink.copy_from(source)
- end
-
- it_behaves_like 'work package copy'
- end
- end
- end
-end
diff --git a/spec/models/work_package/work_package_planning_spec.rb b/spec/models/work_package/work_package_planning_spec.rb
index bc3b38243cb..554b8354297 100644
--- a/spec/models/work_package/work_package_planning_spec.rb
+++ b/spec/models/work_package/work_package_planning_spec.rb
@@ -249,7 +249,7 @@ describe WorkPackage, type: :model do
it "has an initial journal, so that it's creation shows up in activity" do
expect(pe.journals.size).to eq(1)
- changes = pe.journals.first.changed_data.to_hash
+ changes = pe.journals.first.details.to_hash
expect(changes.size).to eq(11)
@@ -271,7 +271,7 @@ describe WorkPackage, type: :model do
pe.update_attribute(:due_date, Date.new(2012, 2, 1))
expect(pe.journals.size).to eq(2)
- changes = pe.journals.last.changed_data
+ changes = pe.journals.last.details
expect(changes.size).to eq(1)
@@ -311,7 +311,7 @@ describe WorkPackage, type: :model do
pe.reload
expect(pe.journals.size).to eq(3)
- changes = pe.journals.last.changed_data
+ changes = pe.journals.last.details
expect(changes.size).to eq(1)
expect(changes).to include(:start_date)
diff --git a/spec/models/work_package_spec.rb b/spec/models/work_package_spec.rb
index 1843217af5e..f5d5489ab4f 100644
--- a/spec/models/work_package_spec.rb
+++ b/spec/models/work_package_spec.rb
@@ -481,170 +481,114 @@ describe WorkPackage, type: :model do
end
end
- describe '#move' do
- let(:work_package) {
- FactoryGirl.create(:work_package,
- project: project,
- type: type)
+ describe '#copy_from' do
+ let(:type) { FactoryGirl.create(:type_standard) }
+ let(:project) { FactoryGirl.create(:project, types: [type]) }
+ let(:custom_field) {
+ FactoryGirl.create(:work_package_custom_field,
+ name: 'Database',
+ field_format: 'list',
+ possible_values: ['MySQL', 'PostgreSQL', 'Oracle'],
+ is_required: true)
}
- let(:target_project) { FactoryGirl.create(:project) }
- shared_examples_for 'moved work package' do
- subject { work_package.project }
+ let(:source) { FactoryGirl.build(:work_package) }
+ let(:sink) { FactoryGirl.build(:work_package) }
- it { is_expected.to eq(target_project) }
+ before do
+ def self.change_custom_field_value(work_package, value)
+ work_package.custom_field_values = { custom_field.id => value } unless value.nil?
+ work_package.save
+ end
end
- describe '#time_entries' do
- let(:time_entry_1) {
- FactoryGirl.create(:time_entry,
- project: project,
- work_package: work_package)
- }
- let(:time_entry_2) {
- FactoryGirl.create(:time_entry,
- project: project,
- work_package: work_package)
- }
+ before do
+ project.work_package_custom_fields << custom_field
+ type.custom_fields << custom_field
- before do
- time_entry_1
- time_entry_2
-
- work_package.reload
- work_package.move_to_project(target_project)
-
- time_entry_1.reload
- time_entry_2.reload
- end
-
- context 'time entry 1' do
- subject { work_package.time_entries }
-
- it { is_expected.to include(time_entry_1) }
- end
-
- context 'time entry 2' do
- subject { work_package.time_entries }
-
- it { is_expected.to include(time_entry_2) }
- end
-
- it_behaves_like 'moved work package'
+ source.save
end
- describe '#category' do
- let(:category) {
- FactoryGirl.create(:category,
- project: project)
- }
+ before do
+ source.project_id = project.id
+ change_custom_field_value(source, 'MySQL')
+ end
- before do
- work_package.category = category
- work_package.save!
+ shared_examples_for 'work package copy' do
+ context 'subject' do
+ subject { sink.subject }
- work_package.reload
+ it { is_expected.to eq(source.subject) }
end
- context 'with same category' do
- let(:target_category) {
- FactoryGirl.create(:category,
- name: category.name,
- project: target_project)
- }
+ context 'type' do
+ subject { sink.type }
+
+ it { is_expected.to eq(source.type) }
+ end
+
+ context 'status' do
+ subject { sink.status }
+
+ it { is_expected.to eq(source.status) }
+ end
+
+ context 'project' do
+ subject { sink.project_id }
+
+ it { is_expected.to eq(project_id) }
+ end
+
+ context 'watchers' do
+ subject { sink.watchers.map(&:user_id) }
+
+ it do
+ is_expected.to match_array(source.watchers.map(&:user_id))
+ sink.watchers.each { |w| expect(w).to be_valid }
+ end
+ end
+ end
+
+ shared_examples_for 'work package copy with custom field' do
+ it_behaves_like 'work package copy'
+
+ context 'custom_field' do
+ subject { sink.custom_value_for(custom_field.id).value }
+
+ it { is_expected.to eq('MySQL') }
+ end
+ end
+
+ context 'with project' do
+ let(:project_id) { source.project_id }
+
+ describe 'should copy project' do
+
+ before { sink.copy_from(source) }
+
+ it_behaves_like 'work package copy with custom field'
+ end
+
+ describe 'should not copy excluded project' do
+ let(:project_id) { sink.project_id }
+
+ before { sink.copy_from(source, exclude: [:project_id]) }
+
+ it_behaves_like 'work package copy'
+ end
+
+ describe 'should copy over watchers' do
+ let(:project_id) { sink.project_id }
+ let(:stub_user) { FactoryGirl.create(:user, member_in_project: project) }
before do
- target_category
+ source.watchers.build(user: stub_user, watchable: source)
- work_package.move_to_project(target_project)
+ sink.copy_from(source)
end
- describe 'category moved' do
- subject { work_package.category_id }
-
- it { is_expected.to eq(target_category.id) }
- end
-
- it_behaves_like 'moved work package'
+ it_behaves_like 'work package copy'
end
-
- context 'w/o target category' do
- before do work_package.move_to_project(target_project) end
-
- describe 'category discarded' do
- subject { work_package.category_id }
-
- it { is_expected.to be_nil }
- end
-
- it_behaves_like 'moved work package'
- end
- end
-
- describe '#version' do
- let(:sharing) { 'none' }
- let(:version) {
- FactoryGirl.create(:version,
- status: 'open',
- project: project,
- sharing: sharing)
- }
- let(:work_package) {
- FactoryGirl.create(:work_package,
- fixed_version: version,
- project: project)
- }
-
- before do work_package.move_to_project(target_project) end
-
- it_behaves_like 'moved work package'
-
- context 'unshared version' do
- subject { work_package.fixed_version }
-
- it { is_expected.to be_nil }
- end
-
- context 'system wide shared version' do
- let(:sharing) { 'system' }
-
- subject { work_package.fixed_version }
-
- it { is_expected.to eq(version) }
- end
-
- context 'move work package in project hierarchy' do
- let(:target_project) {
- FactoryGirl.create(:project,
- parent: project)
- }
-
- context 'unshared version' do
- subject { work_package.fixed_version }
-
- it { is_expected.to be_nil }
- end
-
- context 'shared version' do
- let(:sharing) { 'tree' }
-
- subject { work_package.fixed_version }
-
- it { is_expected.to eq(version) }
- end
- end
- end
-
- describe '#type' do
- let(:target_type) { FactoryGirl.create(:type) }
- let(:target_project) {
- FactoryGirl.create(:project,
- types: [target_type])
- }
-
- subject { work_package.move_to_project(target_project) }
-
- it { is_expected.to be_falsey }
end
end
@@ -1037,7 +981,7 @@ describe WorkPackage, type: :model do
it { is_expected.not_to be_nil }
end
- let(:expected_users) { work_package.author.mail }
+ let(:expected_users) { work_package.author }
it_behaves_like 'includes expected users'
end
@@ -1051,7 +995,7 @@ describe WorkPackage, type: :model do
it { is_expected.not_to be_nil }
end
- let(:expected_users) { work_package.assigned_to.mail }
+ let(:expected_users) { work_package.assigned_to }
it_behaves_like 'includes expected users'
end
@@ -1333,36 +1277,104 @@ describe WorkPackage, type: :model do
end
describe '#allowed_target_projects_on_move' do
- let(:admin_user) { FactoryGirl.create :admin }
- let(:valid_user) { FactoryGirl.create :user }
let(:project) { FactoryGirl.create :project }
- context 'admin user' do
- before do
- allow(User).to receive(:current).and_return admin_user
- project
+ subject { WorkPackage.allowed_target_projects_on_move(user) }
+
+ before do
+ allow(User).to receive(:current).and_return user
+ project
+ end
+
+ shared_examples_for 'has the permission to see projects' do
+ it 'sees the project' do
+ is_expected.to match_array [project]
end
- subject { WorkPackage.allowed_target_projects_on_move.count }
+ it 'does not see the archived project' do
+ project.update_attribute(:status, Project::STATUS_ARCHIVED)
- it 'sees all active projects' do
- is_expected.to eq Project.active.count
+ is_expected.to match_array []
+ end
+
+ it 'does not see the project having the work package module disabled' do
+ enabled_modules = project.enabled_module_names.delete(:work_package_tracking)
+ project.enabled_module_names = enabled_modules
+ project.save!
+
+ is_expected.to match_array []
+ end
+ end
+
+ shared_examples_for 'lacks the permission to see projects' do
+ it 'does not see the project' do
+ is_expected.to match_array []
+ end
+ end
+
+ context 'admin user' do
+ let(:admin_user) { FactoryGirl.create :admin }
+
+ it_behaves_like 'has the permission to see projects' do
+ let(:user) { admin_user }
end
end
context 'non admin user' do
- before do
- allow(User).to receive(:current).and_return valid_user
+ let(:role) { FactoryGirl.build(:role, permissions: user_in_project_permissions) }
+ let(:user_in_project_permissions) { [:move_work_packages] }
+ let(:user_in_project) {
+ FactoryGirl.build :user,
+ member_in_project: project,
+ member_through_role: role
+ }
- role = FactoryGirl.create :role, permissions: [:move_work_packages]
+ it_behaves_like 'has the permission to see projects' do
+ before do
+ user_in_project.save!
+ end
- FactoryGirl.create(:member, user: valid_user, project: project, roles: [role])
+ let(:user) { user_in_project }
end
- subject { WorkPackage.allowed_target_projects_on_move.count }
+ it_behaves_like 'lacks the permission to see projects' do
+ let(:user_in_project_permissions) { [] }
- it 'sees all active projects' do
- is_expected.to eq Project.active.count
+ before do
+ user_in_project.save!
+ end
+
+ let(:user) { user_in_project }
+ end
+ end
+
+ context 'non member user' do
+ it_behaves_like 'lacks the permission to see projects' do
+ before do
+ project.update_attribute(:is_public, true)
+ FactoryGirl.create(:non_member, permissions: [])
+ end
+
+ let(:user) { FactoryGirl.create(:user) }
+ end
+
+ it_behaves_like 'has the permission to see projects' do
+ before do
+ project.update_attribute(:is_public, true)
+ FactoryGirl.create(:non_member, permissions: [:move_work_packages])
+ end
+
+ let(:user) { FactoryGirl.create(:user) }
+ end
+ end
+
+ context 'anonymous user' do
+ it_behaves_like 'lacks the permission to see projects' do
+ before do
+ project.update_attribute(:is_public, true)
+ end
+
+ let(:user) { FactoryGirl.create(:anonymous) }
end
end
end
@@ -1491,20 +1503,20 @@ describe WorkPackage, type: :model do
it 'should not include certain attributes' do
recreated_journal = @issue.recreate_initial_journal!
- expect(recreated_journal.changed_data.include?('rgt')).to eq(false)
- expect(recreated_journal.changed_data.include?('lft')).to eq(false)
- expect(recreated_journal.changed_data.include?('lock_version')).to eq(false)
- expect(recreated_journal.changed_data.include?('updated_at')).to eq(false)
- expect(recreated_journal.changed_data.include?('updated_on')).to eq(false)
- expect(recreated_journal.changed_data.include?('id')).to eq(false)
- expect(recreated_journal.changed_data.include?('type')).to eq(false)
- expect(recreated_journal.changed_data.include?('root_id')).to eq(false)
+ expect(recreated_journal.details.include?('rgt')).to eq(false)
+ expect(recreated_journal.details.include?('lft')).to eq(false)
+ expect(recreated_journal.details.include?('lock_version')).to eq(false)
+ expect(recreated_journal.details.include?('updated_at')).to eq(false)
+ expect(recreated_journal.details.include?('updated_on')).to eq(false)
+ expect(recreated_journal.details.include?('id')).to eq(false)
+ expect(recreated_journal.details.include?('type')).to eq(false)
+ expect(recreated_journal.details.include?('root_id')).to eq(false)
end
it 'should not include useless transitions' do
recreated_journal = @issue.recreate_initial_journal!
- recreated_journal.changed_data.values.each do |change|
+ recreated_journal.details.values.each do |change|
expect(change.first).not_to eq(change.last)
end
end
diff --git a/spec/requests/api/v3/work_packages_api_spec.rb b/spec/requests/api/v3/activities_by_work_package_resource_spec.rb
similarity index 97%
rename from spec/requests/api/v3/work_packages_api_spec.rb
rename to spec/requests/api/v3/activities_by_work_package_resource_spec.rb
index 86435bdfc1d..cade61fdd3f 100644
--- a/spec/requests/api/v3/work_packages_api_spec.rb
+++ b/spec/requests/api/v3/activities_by_work_package_resource_spec.rb
@@ -29,7 +29,7 @@
require 'spec_helper'
require 'rack/test'
-describe API::V3::WorkPackages::WorkPackagesAPI, type: :request do
+describe API::V3::Activities::ActivitiesByWorkPackageAPI, type: :request do
include API::V3::Utilities::PathHelper
let(:admin) { FactoryGirl.create(:admin) }
diff --git a/spec/requests/api/v3/activity_resource_spec.rb b/spec/requests/api/v3/activity_resource_spec.rb
index f9bb1eaadcc..7c12eae4049 100644
--- a/spec/requests/api/v3/activity_resource_spec.rb
+++ b/spec/requests/api/v3/activity_resource_spec.rb
@@ -33,11 +33,19 @@ describe 'API v3 Activity resource', type: :request do
include Rack::Test::Methods
include API::V3::Utilities::PathHelper
- let(:current_user) { FactoryGirl.create(:user) }
+ let(:current_user) {
+ FactoryGirl.create(:user, member_in_project: project, member_through_role: role)
+ }
let(:project) { FactoryGirl.create(:project, is_public: false) }
let(:work_package) { FactoryGirl.create(:work_package, author: current_user, project: project) }
- let(:role) { FactoryGirl.create(:role, permissions: [:view_work_packages]) }
- let(:activity) { FactoryGirl.create(:work_package_journal, journable: work_package) }
+ let(:role) { FactoryGirl.create(:role, permissions: permissions) }
+ let(:permissions) { [:view_work_packages, :edit_work_package_notes] }
+ let(:activity) { work_package.journals.first }
+
+ before do
+ allow(User).to receive(:current).and_return current_user
+ work_package.save!
+ end
describe '#get' do
subject(:response) { last_response }
@@ -45,10 +53,6 @@ describe 'API v3 Activity resource', type: :request do
context 'logged in user' do
let(:get_path) { api_v3_paths.activity activity.id }
before do
- allow(User).to receive(:current).and_return current_user
- member = FactoryGirl.build(:member, user: current_user, project: project)
- member.role_ids = [role.id]
- member.save!
get get_path
end
@@ -80,9 +84,40 @@ describe 'API v3 Activity resource', type: :request do
end
end
- it_behaves_like 'handling anonymous user', 'Activity', '/api/v3/activities/%s' do
+ it_behaves_like 'handling anonymous user' do
let(:project) { FactoryGirl.create(:project, is_public: true) }
- let(:id) { activity.id }
+ let(:path) { api_v3_paths.activity activity.id }
+ end
+ end
+
+ describe '#patch' do
+ subject(:response) { last_response }
+ let(:patch_path) { api_v3_paths.activity activity.id }
+ let(:valid_params) {
+ {
+ comment: 'a fancy comment!'
+ }
+ }
+
+ context 'authorized user' do
+ before do
+ patch patch_path, valid_params.to_json, 'CONTENT_TYPE' => 'application/json'
+ end
+
+ subject(:response) { last_response }
+
+ it 'should respond with HTTP OK' do
+ expect(subject.status).to eq(200)
+ end
+
+ it 'changes the comment' do
+ activity.reload
+ expect(activity.notes).to eql 'a fancy comment!'
+ end
+
+ it 'responds with the updated activity' do
+ expect(subject.body).to be_json_eql('a fancy comment!'.to_json).at_path('comment/raw')
+ end
end
end
end
diff --git a/spec/requests/api/v3/repositories/revisions_by_work_package_resource_spec.rb b/spec/requests/api/v3/repositories/revisions_by_work_package_resource_spec.rb
new file mode 100644
index 00000000000..5e43af9ab2b
--- /dev/null
+++ b/spec/requests/api/v3/repositories/revisions_by_work_package_resource_spec.rb
@@ -0,0 +1,98 @@
+#-- copyright
+# OpenProject is a project management system.
+# Copyright (C) 2012-2015 the OpenProject Foundation (OPF)
+#
+# This program is free software; you can redistribute it and/or
+# modify it under the terms of the GNU General Public License version 3.
+#
+# OpenProject is a fork of ChiliProject, which is a fork of Redmine. The copyright follows:
+# Copyright (C) 2006-2013 Jean-Philippe Lang
+# Copyright (C) 2010-2013 the ChiliProject Team
+#
+# This program is free software; you can redistribute it and/or
+# modify it under the terms of the GNU General Public License
+# as published by the Free Software Foundation; either version 2
+# of the License, or (at your option) any later version.
+#
+# This program is distributed in the hope that it will be useful,
+# but WITHOUT ANY WARRANTY; without even the implied warranty of
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+# GNU General Public License for more details.
+#
+# You should have received a copy of the GNU General Public License
+# along with this program; if not, write to the Free Software
+# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
+#
+# See doc/COPYRIGHT.rdoc for more details.
+#++
+
+require 'spec_helper'
+require 'rack/test'
+
+describe 'API v3 Revisions by work package resource', type: :request do
+ include Rack::Test::Methods
+ include API::V3::Utilities::PathHelper
+ include FileHelpers
+
+ let(:current_user) {
+ FactoryGirl.create(:user,
+ member_in_project: project,
+ member_through_role: role)
+ }
+ let(:project) { FactoryGirl.create(:project, is_public: false) }
+ let(:role) { FactoryGirl.create(:role, permissions: permissions) }
+ let(:permissions) { [:view_work_packages, :view_changesets] }
+ let(:repository) { FactoryGirl.create(:repository_subversion, project: project) }
+ let(:work_package) { FactoryGirl.create(:work_package, author: current_user, project: project) }
+ let(:revisions) { [] }
+
+ subject(:response) { last_response }
+
+ before do
+ allow(User).to receive(:current).and_return current_user
+ end
+
+ describe '#get' do
+ let(:get_path) { api_v3_paths.work_package_revisions work_package.id }
+
+ before do
+ revisions.each do |rev| rev.save! end
+ get get_path
+ end
+
+ it 'should respond with 200' do
+ expect(subject.status).to eq(200)
+ end
+
+ it_behaves_like 'API V3 collection response', 0, 0, 'Revision'
+
+
+ context 'with existing revisions' do
+ let(:revisions) {
+ FactoryGirl.build_list(:changeset,
+ 5,
+ comments: "This commit references ##{work_package.id}",
+ repository: repository
+ )
+ }
+
+ it_behaves_like 'API V3 collection response', 5, 5, 'Revision'
+ end
+
+ context 'user unauthorized to view work package' do
+ let(:current_user) { FactoryGirl.create(:user) }
+
+ it 'should respond with 404' do
+ expect(subject.status).to eq(404)
+ end
+ end
+
+ context 'user unauthorized to view revisions' do
+ let(:permissions) { [:view_work_packages] }
+
+ it 'should respond with 403' do
+ expect(subject.status).to eq(403)
+ end
+ end
+ end
+end
diff --git a/spec/requests/api/v3/repositories/revisions_resource_spec.rb b/spec/requests/api/v3/repositories/revisions_resource_spec.rb
new file mode 100644
index 00000000000..e0948814a11
--- /dev/null
+++ b/spec/requests/api/v3/repositories/revisions_resource_spec.rb
@@ -0,0 +1,97 @@
+#-- copyright
+# OpenProject is a project management system.
+# Copyright (C) 2012-2015 the OpenProject Foundation (OPF)
+#
+# This program is free software; you can redistribute it and/or
+# modify it under the terms of the GNU General Public License version 3.
+#
+# OpenProject is a fork of ChiliProject, which is a fork of Redmine. The copyright follows:
+# Copyright (C) 2006-2013 Jean-Philippe Lang
+# Copyright (C) 2010-2013 the ChiliProject Team
+#
+# This program is free software; you can redistribute it and/or
+# modify it under the terms of the GNU General Public License
+# as published by the Free Software Foundation; either version 2
+# of the License, or (at your option) any later version.
+#
+# This program is distributed in the hope that it will be useful,
+# but WITHOUT ANY WARRANTY; without even the implied warranty of
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+# GNU General Public License for more details.
+#
+# You should have received a copy of the GNU General Public License
+# along with this program; if not, write to the Free Software
+# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
+#
+# See doc/COPYRIGHT.rdoc for more details.
+#++
+
+require 'spec_helper'
+require 'rack/test'
+
+describe 'API v3 Revisions resource', type: :request do
+ include Rack::Test::Methods
+ include Capybara::RSpecMatchers
+ include API::V3::Utilities::PathHelper
+
+ let(:revision) {
+ FactoryGirl.create(:changeset,
+ repository: repository,
+ comments: 'Some commit message',
+ committer: 'foo bar '
+ )
+ }
+ let(:repository) {
+ FactoryGirl.create(:repository_subversion, project: project)
+ }
+ let(:project) {
+ FactoryGirl.create(:project, identifier: 'test_project', is_public: false)
+ }
+ let(:role) {
+ FactoryGirl.create(:role,
+ permissions: [:view_changesets])
+ }
+ let(:current_user) {
+ FactoryGirl.create(:user, member_in_project: project, member_through_role: role)
+ }
+
+ let(:unauthorized_user) { FactoryGirl.create(:user) }
+
+ describe '#get' do
+ let(:get_path) { api_v3_paths.revision revision.id }
+
+ context 'when acting as a user with permission to view revisions' do
+ before(:each) do
+ allow(User).to receive(:current).and_return current_user
+ get get_path
+ end
+
+ it 'should respond with 200' do
+ expect(last_response.status).to eq(200)
+ end
+
+ describe 'response body' do
+ subject(:response) { last_response.body }
+
+ it 'should respond with revision in HAL+JSON format' do
+ is_expected.to be_json_eql(revision.id.to_json).at_path('id')
+ end
+ end
+
+ context 'requesting nonexistent revision' do
+ let(:get_path) { api_v3_paths.revision 909090 }
+
+ it_behaves_like 'not found'
+ end
+ end
+
+ context 'when acting as an user without permission to view work package' do
+ before(:each) do
+ allow(User).to receive(:current).and_return unauthorized_user
+ get get_path
+ end
+
+ it_behaves_like 'not found'
+ end
+ end
+end
diff --git a/spec/requests/api/v3/support/authorization.rb b/spec/requests/api/v3/support/authorization.rb
index 29f6906e753..d0fa4203af4 100644
--- a/spec/requests/api/v3/support/authorization.rb
+++ b/spec/requests/api/v3/support/authorization.rb
@@ -28,27 +28,16 @@
require 'spec_helper'
-shared_examples_for 'handling anonymous user' do |type, path|
+shared_examples_for 'handling anonymous user' do
context 'anonymous user' do
- let(:get_path) { path % [id] }
-
- context 'when access for anonymous user is allowed' do
- before do get get_path end
-
- it 'should respond with 200' do
- expect(subject.status).to eq(200)
- end
-
- it 'should respond with correct type' do
- expect(subject.body).to include_json(type.to_json).at_path('_type')
- expect(subject.body).to be_json_eql(id.to_json).at_path('id')
- end
+ before do
+ allow(User).to receive(:current).and_return(User.anonymous)
end
context 'when access for anonymous user is not allowed' do
before do
allow(Setting).to receive(:login_required?).and_return(true)
- get get_path
+ get path
end
it_behaves_like 'unauthenticated access'
diff --git a/spec/requests/api/v3/user_resource_spec.rb b/spec/requests/api/v3/user_resource_spec.rb
index 22338dea80f..bdf115eabdc 100644
--- a/spec/requests/api/v3/user_resource_spec.rb
+++ b/spec/requests/api/v3/user_resource_spec.rb
@@ -66,8 +66,8 @@ describe 'API v3 User resource', type: :request do
end
end
- it_behaves_like 'handling anonymous user', 'User', '/api/v3/users/%s' do
- let(:id) { user.id }
+ it_behaves_like 'handling anonymous user' do
+ let(:path) { api_v3_paths.user user.id }
end
end
diff --git a/spec/routing/repositories_routing_spec.rb b/spec/routing/repositories_routing_spec.rb
index 23fcddb5e84..dc4b98dc034 100644
--- a/spec/routing/repositories_routing_spec.rb
+++ b/spec/routing/repositories_routing_spec.rb
@@ -31,219 +31,267 @@ require 'spec_helper'
describe RepositoriesController, type: :routing do
describe 'show' do
it {
- expect(get('/projects/testproject/repository')).to route_to(controller: 'repositories',
- action: 'show',
- project_id: 'testproject')
+ expect(get('/projects/testproject/repository'))
+ .to route_to(controller: 'repositories',
+ action: 'show',
+ project_id: 'testproject')
}
it {
- expect(get('/projects/testproject/repository/path/to/file.c')).to route_to(controller: 'repositories',
- action: 'show',
- project_id: 'testproject',
- path: 'path/to/file.c')
+ expect(get('/projects/testproject/repository/path/to/file.c'))
+ .to route_to(controller: 'repositories',
+ action: 'show',
+ project_id: 'testproject',
+ path: 'path/to/file.c')
}
it {
- expect(get('/projects/testproject/repository/revisions/5')).to route_to(controller: 'repositories',
- action: 'show',
- rev: '5',
- project_id: 'testproject')
+ expect(get('/projects/testproject/repository/revisions/5'))
+ .to route_to(controller: 'repositories',
+ action: 'show',
+ rev: '5',
+ project_id: 'testproject')
}
end
describe 'edit' do
it {
- expect(get('/projects/testproject/repository/edit')).to route_to(controller: 'repositories',
- action: 'edit',
- project_id: 'testproject')
+ expect(get('/projects/testproject/repository/edit'))
+ .to route_to(controller: 'repositories',
+ action: 'edit',
+ project_id: 'testproject')
}
+ end
+ describe 'create' do
it {
- expect(post('/projects/testproject/repository/edit')).to route_to(controller: 'repositories',
- action: 'edit',
- project_id: 'testproject')
+ expect(post('/projects/testproject/repository/'))
+ .to route_to(controller: 'repositories',
+ action: 'create',
+ project_id: 'testproject')
+ }
+ end
+
+ describe 'update' do
+ it {
+ expect(put('/projects/testproject/repository/'))
+ .to route_to(controller: 'repositories',
+ action: 'update',
+ project_id: 'testproject')
}
end
describe 'revisions' do
it {
- expect(get('/projects/testproject/repository/revisions')).to route_to(controller: 'repositories',
- action: 'revisions',
- project_id: 'testproject')
+ expect(get('/projects/testproject/repository/revisions'))
+ .to route_to(controller: 'repositories',
+ action: 'revisions',
+ project_id: 'testproject')
}
it {
- expect(get('/projects/testproject/repository/revisions.atom')).to route_to(controller: 'repositories',
- action: 'revisions',
- project_id: 'testproject',
- format: 'atom')
+ expect(get('/projects/testproject/repository/revisions.atom'))
+ .to route_to(controller: 'repositories',
+ action: 'revisions',
+ project_id: 'testproject',
+ format: 'atom')
}
end
describe 'revision' do
it {
- expect(get('/projects/testproject/repository/revision/2457')).to route_to(controller: 'repositories',
- action: 'revision',
- project_id: 'testproject',
- rev: '2457')
+ expect(get('/projects/testproject/repository/revision/2457'))
+ .to route_to(controller: 'repositories',
+ action: 'revision',
+ project_id: 'testproject',
+ rev: '2457')
}
it {
- expect(get('/projects/testproject/repository/revision')).to route_to(controller: 'repositories',
- action: 'revision',
- project_id: 'testproject')
+ expect(get('/projects/testproject/repository/revision'))
+ .to route_to(controller: 'repositories',
+ action: 'revision',
+ project_id: 'testproject')
}
end
describe 'diff' do
it {
- expect(get('/projects/testproject/repository/revisions/2457/diff')).to route_to(controller: 'repositories',
- action: 'diff',
- project_id: 'testproject',
- rev: '2457')
+ expect(get('/projects/testproject/repository/revisions/2457/diff'))
+ .to route_to(controller: 'repositories',
+ action: 'diff',
+ project_id: 'testproject',
+ rev: '2457')
}
it {
- expect(get('/projects/testproject/repository/revisions/2457/diff.diff')).to route_to(controller: 'repositories',
- action: 'diff',
- project_id: 'testproject',
- rev: '2457',
- format: 'diff')
+ expect(get('/projects/testproject/repository/revisions/2457/diff.diff'))
+ .to route_to(controller: 'repositories',
+ action: 'diff',
+ project_id: 'testproject',
+ rev: '2457',
+ format: 'diff')
}
it {
- expect(get('/projects/testproject/repository/diff')).to route_to(controller: 'repositories',
- action: 'diff',
- project_id: 'testproject')
+ expect(get('/projects/testproject/repository/diff'))
+ .to route_to(controller: 'repositories',
+ action: 'diff',
+ project_id: 'testproject')
}
it {
- expect(get('/projects/testproject/repository/diff/path/to/file.c')).to route_to(controller: 'repositories',
- action: 'diff',
- project_id: 'testproject',
- path: 'path/to/file.c')
+ expect(get('/projects/testproject/repository/diff/path/to/file.c'))
+ .to route_to(controller: 'repositories',
+ action: 'diff',
+ project_id: 'testproject',
+ path: 'path/to/file.c')
}
it {
- expect(get('/projects/testproject/repository/revisions/2/diff/path/to/file.c')).to route_to(controller: 'repositories',
- action: 'diff',
- project_id: 'testproject',
- path: 'path/to/file.c',
- rev: '2')
+ expect(get('/projects/testproject/repository/revisions/2/diff/path/to/file.c'))
+ .to route_to(controller: 'repositories',
+ action: 'diff',
+ project_id: 'testproject',
+ path: 'path/to/file.c',
+ rev: '2')
}
end
describe 'browse' do
it {
- expect(get('/projects/testproject/repository/browse/path/to/file.c')).to route_to(controller: 'repositories',
- action: 'browse',
- project_id: 'testproject',
- path: 'path/to/file.c')
+ expect(get('/projects/testproject/repository/browse/path/to/file.c'))
+ .to route_to(controller: 'repositories',
+ action: 'browse',
+ project_id: 'testproject',
+ path: 'path/to/file.c')
}
end
describe 'entry' do
it {
- expect(get('/projects/testproject/repository/entry/path/to/file.c')).to route_to(controller: 'repositories',
- action: 'entry',
- project_id: 'testproject',
- path: 'path/to/file.c')
+ expect(get('/projects/testproject/repository/entry/path/to/file.c'))
+ .to route_to(controller: 'repositories',
+ action: 'entry',
+ project_id: 'testproject',
+ path: 'path/to/file.c')
}
it {
- expect(get('/projects/testproject/repository/revisions/2/entry/path/to/file.c')).to route_to(controller: 'repositories',
- action: 'entry',
- project_id: 'testproject',
- path: 'path/to/file.c',
- rev: '2')
+ expect(get('/projects/testproject/repository/revisions/2/entry/path/to/file.c'))
+ .to route_to(controller: 'repositories',
+ action: 'entry',
+ project_id: 'testproject',
+ path: 'path/to/file.c',
+ rev: '2')
}
it {
- expect(get('/projects/testproject/repository/raw/path/to/file.c')).to route_to(controller: 'repositories',
- action: 'entry',
- project_id: 'testproject',
- path: 'path/to/file.c',
- format: 'raw')
+ expect(get('/projects/testproject/repository/raw/path/to/file.c'))
+ .to route_to(controller: 'repositories',
+ action: 'entry',
+ project_id: 'testproject',
+ path: 'path/to/file.c',
+ format: 'raw')
}
it {
- expect(get('/projects/testproject/repository/revisions/master/raw/path/to/file.c')).to route_to(controller: 'repositories',
- action: 'entry',
- project_id: 'testproject',
- path: 'path/to/file.c',
- rev: 'master',
- format: 'raw')
+ expect(get('/projects/testproject/repository/revisions/master/raw/path/to/file.c'))
+ .to route_to(controller: 'repositories',
+ action: 'entry',
+ project_id: 'testproject',
+ path: 'path/to/file.c',
+ rev: 'master',
+ format: 'raw')
}
end
describe 'annotate' do
it {
- expect(get('/projects/testproject/repository/annotate/path/to/file.c')).to route_to(controller: 'repositories',
- action: 'annotate',
- project_id: 'testproject',
- path: 'path/to/file.c')
+ expect(get('/projects/testproject/repository/annotate/path/to/file.c'))
+ .to route_to(controller: 'repositories',
+ action: 'annotate',
+ project_id: 'testproject',
+ path: 'path/to/file.c')
}
it {
- expect(get('/projects/testproject/repository/revisions/5/annotate/path/to/file.c')).to route_to(controller: 'repositories',
- action: 'annotate',
- project_id: 'testproject',
- path: 'path/to/file.c',
- rev: '5')
+ expect(get('/projects/testproject/repository/revisions/5/annotate/path/to/file.c'))
+ .to route_to(controller: 'repositories',
+ action: 'annotate',
+ project_id: 'testproject',
+ path: 'path/to/file.c',
+ rev: '5')
}
end
describe 'changes' do
it {
- expect(get('/projects/testproject/repository/changes/path/to/file.c')).to route_to(controller: 'repositories',
- action: 'changes',
- project_id: 'testproject',
- path: 'path/to/file.c')
+ expect(get('/projects/testproject/repository/changes/path/to/file.c'))
+ .to route_to(controller: 'repositories',
+ action: 'changes',
+ project_id: 'testproject',
+ path: 'path/to/file.c')
}
it {
- expect(get('/projects/testproject/repository/revisions/5/changes/path/to/file.c')).to route_to(controller: 'repositories',
- action: 'changes',
- project_id: 'testproject',
- path: 'path/to/file.c',
- rev: '5')
+ expect(get('/projects/testproject/repository/revisions/5/changes/path/to/file.c'))
+ .to route_to(controller: 'repositories',
+ action: 'changes',
+ project_id: 'testproject',
+ path: 'path/to/file.c',
+ rev: '5')
}
end
describe 'stats' do
it {
- expect(get('/projects/testproject/repository/statistics')).to route_to(controller: 'repositories',
- action: 'stats',
- project_id: 'testproject')
+ expect(get('/projects/testproject/repository/statistics'))
+ .to route_to(controller: 'repositories',
+ action: 'stats',
+ project_id: 'testproject')
}
end
describe 'committers' do
it {
- expect(get('/projects/testproject/repository/committers')).to route_to(controller: 'repositories',
- action: 'committers',
- project_id: 'testproject')
+ expect(get('/projects/testproject/repository/committers'))
+ .to route_to(controller: 'repositories',
+ action: 'committers',
+ project_id: 'testproject')
}
it {
- expect(post('/projects/testproject/repository/committers')).to route_to(controller: 'repositories',
- action: 'committers',
- project_id: 'testproject')
+ expect(post('/projects/testproject/repository/committers'))
+ .to route_to(controller: 'repositories',
+ action: 'committers',
+ project_id: 'testproject')
}
end
describe 'graph' do
it {
- expect(get('/projects/testproject/repository/graph')).to route_to(controller: 'repositories',
- action: 'graph',
- project_id: 'testproject')
+ expect(get('/projects/testproject/repository/graph'))
+ .to route_to(controller: 'repositories',
+ action: 'graph',
+ project_id: 'testproject')
}
end
describe 'destroy' do
it {
- expect(delete('/projects/testproject/repository')).to route_to(controller: 'repositories',
- action: 'destroy',
- project_id: 'testproject')
+ expect(delete('/projects/testproject/repository'))
+ .to route_to(controller: 'repositories',
+ action: 'destroy',
+ project_id: 'testproject')
+ }
+ end
+
+ describe 'destroy_info' do
+ it {
+ expect(get('/projects/testproject/repository/destroy_info'))
+ .to route_to(controller: 'repositories',
+ action: 'destroy_info',
+ project_id: 'testproject')
}
end
end
diff --git a/spec/services/add_attachment_service_spec.rb b/spec/services/add_attachment_service_spec.rb
new file mode 100644
index 00000000000..97bc8ffaec2
--- /dev/null
+++ b/spec/services/add_attachment_service_spec.rb
@@ -0,0 +1,87 @@
+#-- encoding: UTF-8
+#-- copyright
+# OpenProject is a project management system.
+# Copyright (C) 2012-2015 the OpenProject Foundation (OPF)
+#
+# This program is free software; you can redistribute it and/or
+# modify it under the terms of the GNU General Public License version 3.
+#
+# OpenProject is a fork of ChiliProject, which is a fork of Redmine. The copyright follows:
+# Copyright (C) 2006-2013 Jean-Philippe Lang
+# Copyright (C) 2010-2013 the ChiliProject Team
+#
+# This program is free software; you can redistribute it and/or
+# modify it under the terms of the GNU General Public License
+# as published by the Free Software Foundation; either version 2
+# of the License, or (at your option) any later version.
+#
+# This program is distributed in the hope that it will be useful,
+# but WITHOUT ANY WARRANTY; without even the implied warranty of
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+# GNU General Public License for more details.
+#
+# You should have received a copy of the GNU General Public License
+# along with this program; if not, write to the Free Software
+# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
+#
+# See doc/COPYRIGHT.rdoc for more details.
+
+require 'spec_helper'
+
+describe AddAttachmentService do
+ let(:user) { FactoryGirl.create(:user) }
+ let(:work_package) { FactoryGirl.build(:work_package) }
+ let(:container) { work_package }
+ let(:description) { 'a fancy description' }
+
+ subject { described_class.new(work_package, author: user) }
+
+ describe '#add_attachment' do
+ def call_tested_method
+ subject.add_attachment uploaded_file: FileHelpers.mock_uploaded_file(name: 'foobar.txt'),
+ description: description
+ end
+
+ context 'happy path' do
+ before do
+ call_tested_method
+ end
+
+ it 'should save the attachment' do
+ attachment = Attachment.first
+ expect(attachment.filename).to eq 'foobar.txt'
+ expect(attachment.description).to eq description
+ end
+
+ it 'should add the attachment to the WP' do
+ work_package.reload
+ expect(work_package.attachments).to include Attachment.first
+ end
+
+ it 'should add a journal entry on the WP' do
+ expect(work_package.journals.count).to eq 2 # 1 for WP creation + 1 for the attachment
+ end
+ end
+
+ context "can't save work package" do
+ before do
+ allow(work_package).to receive(:save!)
+ .and_raise(ActiveRecord::RecordInvalid.new(work_package))
+ end
+
+ it 'should raise the exception' do
+ expect { call_tested_method }.to raise_error ActiveRecord::RecordInvalid
+ end
+
+ it 'should not save the attachment' do
+ begin
+ call_tested_method
+ rescue ActiveRecord::RecordInvalid
+ # we expect that to happen
+ end
+
+ expect(Attachment.count).to eq 0
+ end
+ end
+ end
+end
diff --git a/spec/app/services/create_work_package_service_spec.rb b/spec/services/create_work_package_service_spec.rb
similarity index 100%
rename from spec/app/services/create_work_package_service_spec.rb
rename to spec/services/create_work_package_service_spec.rb
diff --git a/spec/services/move_work_package_service_spec.rb b/spec/services/move_work_package_service_spec.rb
new file mode 100644
index 00000000000..09493702fb1
--- /dev/null
+++ b/spec/services/move_work_package_service_spec.rb
@@ -0,0 +1,493 @@
+#-- copyright
+# OpenProject is a project management system.
+# Copyright (C) 2012-2015 the OpenProject Foundation (OPF)
+#
+# This program is free software; you can redistribute it and/or
+# modify it under the terms of the GNU General Public License version 3.
+#
+# OpenProject is a fork of ChiliProject, which is a fork of Redmine. The copyright follows:
+# Copyright (C) 2006-2013 Jean-Philippe Lang
+# Copyright (C) 2010-2013 the ChiliProject Team
+#
+# This program is free software; you can redistribute it and/or
+# modify it under the terms of the GNU General Public License
+# as published by the Free Software Foundation; either version 2
+# of the License, or (at your option) any later version.
+#
+# This program is distributed in the hope that it will be useful,
+# but WITHOUT ANY WARRANTY; without even the implied warranty of
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+# GNU General Public License for more details.
+#
+# You should have received a copy of the GNU General Public License
+# along with this program; if not, write to the Free Software
+# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
+#
+# See doc/COPYRIGHT.rdoc for more details.
+#++
+
+require 'spec_helper'
+
+describe MoveWorkPackageService, type: :model do
+ let(:user) { FactoryGirl.create(:user) }
+ let(:type) { FactoryGirl.create(:type_standard) }
+ let(:project) { FactoryGirl.create(:project, types: [type]) }
+ let(:work_package) {
+ FactoryGirl.create(:work_package,
+ project: project,
+ type: type)
+ }
+ let(:instance) { MoveWorkPackageService.new(work_package, user) }
+
+ before do
+ allow(User).to receive(:current).and_return(user)
+ end
+
+ def mock_allowed_to_move_to_project(project, is_allowed = true)
+ allowed_scope = double('allowed_scope', :'exists?' => is_allowed)
+
+ allow(WorkPackage)
+ .to receive(:allowed_target_projects_on_move)
+ .with(user)
+ .and_return(allowed_scope)
+
+ allow(allowed_scope)
+ .to receive(:where)
+ .with(id: project.id)
+ .and_return(allowed_scope)
+ end
+
+ describe '#call' do
+ context 'when moving' do
+ let(:target_project) { FactoryGirl.create(:project) }
+
+ before do
+ work_package
+
+ mock_allowed_to_move_to_project(target_project, true)
+ end
+
+ shared_examples_for 'moved work package' do
+ subject { work_package.project }
+
+ it { is_expected.to eq(target_project) }
+ end
+
+ context 'the project the work package is moved to' do
+ it_behaves_like 'moved work package' do
+ before do
+ instance.call(target_project)
+ end
+ end
+
+ it 'will not move if the user does not have the permission' do
+ mock_allowed_to_move_to_project(target_project, false)
+
+ instance.call(target_project)
+
+ expect(work_package.project).to eql(project)
+ end
+ end
+
+ describe '#time_entries' do
+ let(:time_entry_1) {
+ FactoryGirl.create(:time_entry,
+ project: project,
+ work_package: work_package)
+ }
+ let(:time_entry_2) {
+ FactoryGirl.create(:time_entry,
+ project: project,
+ work_package: work_package)
+ }
+
+ before do
+ time_entry_1
+ time_entry_2
+
+ work_package.reload
+ instance.call(target_project)
+
+ time_entry_1.reload
+ time_entry_2.reload
+ end
+
+ context 'time entry 1' do
+ subject { work_package.time_entries }
+
+ it { is_expected.to include(time_entry_1) }
+ end
+
+ context 'time entry 2' do
+ subject { work_package.time_entries }
+
+ it { is_expected.to include(time_entry_2) }
+ end
+
+ it_behaves_like 'moved work package'
+ end
+
+ describe '#category' do
+ let(:category) {
+ FactoryGirl.create(:category,
+ project: project)
+ }
+
+ before do
+ work_package.category = category
+ work_package.save!
+
+ work_package.reload
+ end
+
+ context 'with same category' do
+ let(:target_category) {
+ FactoryGirl.create(:category,
+ name: category.name,
+ project: target_project)
+ }
+
+ before do
+ target_category
+
+ instance.call(target_project)
+ end
+
+ describe 'category moved' do
+ subject { work_package.category_id }
+
+ it { is_expected.to eq(target_category.id) }
+ end
+
+ it_behaves_like 'moved work package'
+ end
+
+ context 'w/o target category' do
+ before do
+ instance.call(target_project)
+ end
+
+ describe 'category discarded' do
+ subject { work_package.category_id }
+
+ it { is_expected.to be_nil }
+ end
+
+ it_behaves_like 'moved work package'
+ end
+ end
+
+ describe '#version' do
+ let(:sharing) { 'none' }
+ let(:version) {
+ FactoryGirl.create(:version,
+ status: 'open',
+ project: project,
+ sharing: sharing)
+ }
+ let(:work_package) {
+ FactoryGirl.create(:work_package,
+ fixed_version: version,
+ project: project)
+ }
+
+ before do
+ instance.call(target_project)
+ end
+
+ it_behaves_like 'moved work package'
+
+ context 'unshared version' do
+ subject { work_package.fixed_version }
+
+ it { is_expected.to be_nil }
+ end
+
+ context 'system wide shared version' do
+ let(:sharing) { 'system' }
+
+ subject { work_package.fixed_version }
+
+ it { is_expected.to eq(version) }
+ end
+
+ context 'move work package in project hierarchy' do
+ let(:target_project) {
+ FactoryGirl.create(:project,
+ parent: project)
+ }
+
+ context 'unshared version' do
+ subject { work_package.fixed_version }
+
+ it { is_expected.to be_nil }
+ end
+
+ context 'shared version' do
+ let(:sharing) { 'tree' }
+
+ subject { work_package.fixed_version }
+
+ it { is_expected.to eq(version) }
+ end
+ end
+ end
+
+ describe '#type' do
+ let(:target_type) { FactoryGirl.create(:type) }
+ let(:target_project) {
+ FactoryGirl.create(:project,
+ types: [target_type])
+ }
+
+ it 'is false if the current type is not defined for the new project' do
+ expect(instance.call(target_project)).to be_falsey
+ end
+ end
+ end
+
+ describe 'when copying' do
+ let(:custom_field) { FactoryGirl.create(:work_package_custom_field) }
+ let(:source_type) {
+ FactoryGirl.create(:type,
+ custom_fields: [custom_field])
+ }
+ let(:source_project) {
+ FactoryGirl.create(:project,
+ types: [source_type])
+ }
+ let(:work_package) {
+ FactoryGirl.create(:work_package,
+ project: source_project,
+ type: source_type,
+ author: user)
+ }
+ let(:custom_value) {
+ FactoryGirl.create(:work_package_custom_value,
+ custom_field: custom_field,
+ customized: work_package,
+ value: false)
+ }
+
+ shared_examples_for 'copied work package' do
+ subject { copy.id }
+
+ it { is_expected.not_to eq(work_package.id) }
+ end
+
+ describe 'to the same project' do
+ let(:copy) {
+ mock_allowed_to_move_to_project(source_project)
+ instance.call(source_project, nil, copy: true)
+ }
+
+ it_behaves_like 'copied work package'
+
+ context 'project' do
+ subject { copy.project }
+
+ it { is_expected.to eq(source_project) }
+ end
+ end
+
+ describe 'to a different project' do
+ let(:target_type) { FactoryGirl.create(:type) }
+ let(:target_project) {
+ FactoryGirl.create(:project,
+ types: [target_type])
+ }
+ let(:copy) do
+ mock_allowed_to_move_to_project(target_project)
+ instance.call(target_project, target_type, copy: true)
+ end
+
+ it_behaves_like 'copied work package'
+
+ context 'project' do
+ subject { copy.project_id }
+
+ it { is_expected.to eq(target_project.id) }
+ end
+
+ context 'type' do
+ subject { copy.type_id }
+
+ it { is_expected.to eq(target_type.id) }
+ end
+
+ context 'custom_fields' do
+ before do
+ custom_value
+ end
+
+ subject { copy.custom_value_for(custom_field.id) }
+
+ it { is_expected.to be_nil }
+ end
+
+ describe '#attributes' do
+ let(:copy) {
+ mock_allowed_to_move_to_project(target_project)
+ instance.call(target_project,
+ target_type,
+ copy: true,
+ attributes: attributes)
+ }
+
+ context 'assigned_to' do
+ let(:target_user) { FactoryGirl.create(:user) }
+ let(:target_project_member) {
+ FactoryGirl.create(:member,
+ project: target_project,
+ principal: target_user,
+ roles: [FactoryGirl.create(:role)])
+ }
+ let(:attributes) { { assigned_to_id: target_user.id } }
+
+ before do
+ target_project_member
+ end
+
+ it_behaves_like 'copied work package'
+
+ subject { copy.assigned_to_id }
+
+ it { is_expected.to eq(target_user.id) }
+ end
+
+ context 'status' do
+ let(:target_status) { FactoryGirl.create(:status) }
+ let(:attributes) { { status_id: target_status.id } }
+
+ it_behaves_like 'copied work package'
+
+ subject { copy.status_id }
+
+ it { is_expected.to eq(target_status.id) }
+ end
+
+ context 'date' do
+ let(:target_date) { Date.today + 14 }
+
+ context 'start' do
+ let(:attributes) { { start_date: target_date } }
+
+ it_behaves_like 'copied work package'
+
+ subject { copy.start_date }
+
+ it { is_expected.to eq(target_date) }
+ end
+
+ context 'end' do
+ let(:attributes) { { due_date: target_date } }
+
+ it_behaves_like 'copied work package'
+
+ subject { copy.due_date }
+
+ it { is_expected.to eq(target_date) }
+ end
+ end
+ end
+
+ describe 'private project' do
+ let(:role) {
+ FactoryGirl.create(:role,
+ permissions: [:view_work_packages])
+ }
+ let(:target_project) {
+ FactoryGirl.create(:project,
+ is_public: false,
+ types: [target_type])
+ }
+ let(:source_project_member) {
+ FactoryGirl.create(:member,
+ project: source_project,
+ principal: user,
+ roles: [role])
+ }
+
+ before do
+ source_project_member
+ allow(User).to receive(:current).and_return user
+ end
+
+ it_behaves_like 'copied work package'
+
+ context 'pre-condition' do
+ subject { work_package.recipients }
+
+ it { is_expected.to include(work_package.author) }
+ end
+
+ subject { copy.recipients }
+
+ it { is_expected.not_to include(copy.author) }
+ end
+
+ describe 'with children' do
+ let(:target_project) { FactoryGirl.create(:project, types: [source_type]) }
+ let(:instance) { MoveWorkPackageService.new(child, user) }
+ let(:copy) do
+ mock_allowed_to_move_to_project(target_project)
+
+ child.reload
+
+ instance.call(target_project)
+ end
+ let!(:child) {
+ FactoryGirl.create(:work_package, parent: work_package, project: source_project)
+ }
+ let!(:grandchild) {
+ FactoryGirl.create(:work_package, parent: child, project: source_project)
+ }
+
+ context 'cross project relations deactivated' do
+ before do
+ allow(Setting).to receive(:cross_project_work_package_relations?).and_return(false)
+ end
+
+ it do
+ expect(copy).to be_falsy
+ end
+
+ it do
+ expect(child.reload.project).to eql(source_project)
+ end
+
+ describe 'grandchild' do
+ before do
+ copy
+ end
+
+ it { expect(grandchild.reload.project).to eql(source_project) }
+ end
+ end
+
+ context 'cross project relations activated' do
+ before do
+ allow(Setting).to receive(:cross_project_work_package_relations?).and_return(true)
+ end
+
+ it do
+ expect(copy).to be_truthy
+ end
+
+ it do
+ expect(copy.project).to eql(target_project)
+ end
+
+ describe 'grandchild' do
+ before do
+ copy
+ end
+
+ it { expect(grandchild.reload.project).to eql(target_project) }
+ end
+ end
+ end
+ end
+ end
+ end
+end
diff --git a/spec/support/identical_ext.rb b/spec/support/identical_ext.rb
index f59c24a3602..cf2ffb402c6 100644
--- a/spec/support/identical_ext.rb
+++ b/spec/support/identical_ext.rb
@@ -34,9 +34,9 @@ Journal.class_eval do
recreated = o.attributes
original.except!('created_at')
- changed_data.except!('created_on')
+ details.except!('created_on')
recreated.except!('created_at')
- o.changed_data.except!('created_on')
+ o.details.except!('created_on')
original.identical?(recreated)
end
diff --git a/spec/support/repository_helpers.rb b/spec/support/repository_helpers.rb
new file mode 100644
index 00000000000..ab1d4bf01da
--- /dev/null
+++ b/spec/support/repository_helpers.rb
@@ -0,0 +1,85 @@
+#-- encoding: UTF-8
+#-- copyright
+# OpenProject is a project management system.
+# Copyright (C) 2012-2015 the OpenProject Foundation (OPF)
+#
+# This program is free software; you can redistribute it and/or
+# modify it under the terms of the GNU General Public License version 3.
+#
+# OpenProject is a fork of ChiliProject, which is a fork of Redmine. The copyright follows:
+# Copyright (C) 2006-2013 Jean-Philippe Lang
+# Copyright (C) 2010-2013 the ChiliProject Team
+#
+# This program is free software; you can redistribute it and/or
+# modify it under the terms of the GNU General Public License
+# as published by the Free Software Foundation; either version 2
+# of the License, or (at your option) any later version.
+#
+# This program is distributed in the hope that it will be useful,
+# but WITHOUT ANY WARRANTY; without even the implied warranty of
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+# GNU General Public License for more details.
+#
+# You should have received a copy of the GNU General Public License
+# along with this program; if not, write to the Free Software
+# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
+#
+# See doc/COPYRIGHT.rdoc for more details.
+#++
+
+##
+# Create a temporary +vendor+ repository from the stored fixture.
+# Automatically extracts and destroys said repository,
+# however does not provide single example isolation
+# due to performance.
+# As we do not write to the repository, we don't need this kind
+# of isolation.
+def with_filesystem_repository(vendor, command = nil, &block)
+ repo_dir = File.join(Rails.root, 'tmp', 'test', "#{vendor}_repository")
+ fixture = File.join(Rails.root, "spec/fixtures/repositories/#{vendor}_repository.tar.gz")
+
+ before(:all) do
+ ['tar', command].compact.each do |cmd|
+ begin
+ # Avoid `which`, as it's not POSIX
+ Open3.capture2e(cmd, '--version')
+ rescue Errno::ENOENT
+ skip "#{cmd} was not found in PATH. Skipping local repository specs"
+ end
+ end
+
+ # Create repository
+ FileUtils.mkdir_p repo_dir
+ system "tar -xzf #{fixture} -C #{repo_dir}"
+ end
+
+ after(:all) do
+ FileUtils.remove_dir repo_dir
+ end
+
+ block.call(repo_dir)
+end
+
+def with_subversion_repository(&block)
+ with_filesystem_repository('subversion', 'svn', &block)
+end
+
+def with_git_repository(&block)
+ with_filesystem_repository('git', 'git', &block)
+end
+
+##
+# Many specs required any repository to be available,
+# often Filesystem adapter was used, even though
+# no actual filesystem access occured.
+# Instead, we wrap these repository specs in a virtual
+# subversion repository which does not exist on disk.
+def with_virtual_subversion_repository(&block)
+ let(:repository) { FactoryGirl.create(:repository_subversion) }
+
+ before do
+ allow(Setting).to receive(:enabled_scm).and_return(['Subversion'])
+ end
+
+ block.call
+end
diff --git a/spec/support/scm/countable_repository.rb b/spec/support/scm/countable_repository.rb
new file mode 100644
index 00000000000..745b69ee684
--- /dev/null
+++ b/spec/support/scm/countable_repository.rb
@@ -0,0 +1,59 @@
+shared_examples_for 'is a countable repository' do
+ let(:job) { ::Scm::StorageUpdaterJob.new repository }
+ before do
+ allow(::Scm::StorageUpdaterJob).to receive(:new).and_return(job)
+ allow(job).to receive(:repository).and_return(repository)
+ end
+ it 'is countable' do
+ expect(repository.scm).to be_storage_available
+ end
+
+ context 'with patched counter' do
+ let(:count) { 1234 }
+
+ before do
+ allow(repository.scm).to receive(:count_repository!).and_return(count)
+ end
+
+ it 'has has not been counted initially' do
+ expect(repository.required_storage_bytes).to be == 0
+ expect(repository.storage_updated_at).to be_nil
+ end
+
+ it 'counts the repository storage automatically' do
+ expect(repository.required_storage_bytes).to be == 0
+ expect(repository.required_disk_storage).to be == count
+ expect(repository.storage_updated_at).to be >= 1.minute.ago
+ end
+
+ context 'when latest count is outdated' do
+ before do
+ allow(repository).to receive(:storage_updated_at).and_return(24.hours.ago)
+ end
+
+ it 'sucessfuly updates the count to what the adapter returns' do
+ expect(repository.required_storage_bytes).to be == 0
+ expect(repository.required_disk_storage).to be == count
+ end
+ end
+ end
+
+ context 'with real counter' do
+ it 'counts the repository storage automatically' do
+ expect(repository.required_storage_bytes).to be == 0
+ expect(repository.required_disk_storage).to be > 1.kilobyte
+ expect(repository.storage_updated_at).to be >= 1.minute.ago
+ end
+ end
+end
+
+shared_examples_for 'is not a countable repository' do
+ it 'is not countable' do
+ expect(repository.scm).not_to be_storage_available
+ end
+
+ it 'does not return or update the count' do
+ expect(::Scm::StorageUpdaterJob).not_to receive(:new)
+ expect(repository.required_disk_storage).to be_nil
+ end
+end
diff --git a/spec/support/shared/acts_as_watchable.rb b/spec/support/shared/acts_as_watchable.rb
index d3338b7a9fc..ddfae18b09e 100644
--- a/spec/support/shared/acts_as_watchable.rb
+++ b/spec/support/shared/acts_as_watchable.rb
@@ -133,7 +133,7 @@ MESSAGE
subject { model_instance.watcher_recipients }
- it { is_expected.to match_array([watching_user.mail]) }
+ it { is_expected.to match_array([watching_user]) }
context 'when the permission to watch has been removed' do
before do
diff --git a/app/models/notifier.rb b/spec/support/tempdir.rb
similarity index 89%
rename from app/models/notifier.rb
rename to spec/support/tempdir.rb
index d1611b906a6..90a63da3ed0 100644
--- a/app/models/notifier.rb
+++ b/spec/support/tempdir.rb
@@ -26,13 +26,12 @@
#
# See doc/COPYRIGHT.rdoc for more details.
#++
-
-module Notifier
- def self.notify?(event)
- notified_events.include?(event.to_s)
- end
-
- def self.notified_events
- Setting.notified_events.to_a
+shared_context 'with tmpdir' do
+ around do |example|
+ Dir.mktmpdir do |dir|
+ @tmpdir = dir
+ example.run
+ end
end
+ attr_reader :tmpdir
end
diff --git a/spec/workers/mail_notification_jobs/deliver_work_package_notification_job_spec.rb b/spec/workers/mail_notification_jobs/deliver_work_package_notification_job_spec.rb
new file mode 100644
index 00000000000..dd8ba58c742
--- /dev/null
+++ b/spec/workers/mail_notification_jobs/deliver_work_package_notification_job_spec.rb
@@ -0,0 +1,164 @@
+#-- encoding: UTF-8
+#-- copyright
+# OpenProject is a project management system.
+# Copyright (C) 2012-2015 the OpenProject Foundation (OPF)
+#
+# This program is free software; you can redistribute it and/or
+# modify it under the terms of the GNU General Public License version 3.
+#
+# OpenProject is a fork of ChiliProject, which is a fork of Redmine. The copyright follows:
+# Copyright (C) 2006-2013 Jean-Philippe Lang
+# Copyright (C) 2010-2013 the ChiliProject Team
+#
+# This program is free software; you can redistribute it and/or
+# modify it under the terms of the GNU General Public License
+# as published by the Free Software Foundation; either version 2
+# of the License, or (at your option) any later version.
+#
+# This program is distributed in the hope that it will be useful,
+# but WITHOUT ANY WARRANTY; without even the implied warranty of
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+# GNU General Public License for more details.
+#
+# You should have received a copy of the GNU General Public License
+# along with this program; if not, write to the Free Software
+# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
+#
+# See doc/COPYRIGHT.rdoc for more details.
+#++
+
+require 'spec_helper'
+
+describe DeliverWorkPackageNotificationJob, type: :model do
+ let(:project) { FactoryGirl.create(:project) }
+ let(:role) { FactoryGirl.create(:role, permissions: [:view_work_packages]) }
+ let(:recipient) {
+ FactoryGirl.create(:user, member_in_project: project, member_through_role: role)
+ }
+ let(:author) { FactoryGirl.create(:user) }
+ let(:work_package) {
+ FactoryGirl.create(:work_package,
+ project: project,
+ author: author,
+ assigned_to: recipient)
+ }
+ let(:journal) { work_package.journals.first }
+ subject { described_class.new(journal.id, author.id) }
+
+ before do
+ # make sure no actual calls make it into the UserMailer
+ allow(UserMailer).to receive(:work_package_added).and_return(double('mail', deliver: nil))
+ allow(UserMailer).to receive(:work_package_updated).and_return(double('mail', deliver: nil))
+ end
+
+ it 'sends a mail' do
+ expect(UserMailer).to receive(:work_package_added).with(
+ recipient,
+ an_instance_of(Journal::AggregatedJournal),
+ author)
+ subject.perform
+ end
+
+ context 'non-existant journal' do
+ before do
+ journal.destroy
+ end
+
+ it 'sends no mail' do
+ expect(UserMailer).not_to receive(:work_package_added)
+ subject.perform
+ end
+ end
+
+ context 'non-existant author' do
+ before do
+ author.destroy
+ end
+
+ it 'sends a mail' do
+ expect(UserMailer).to receive(:work_package_added)
+ subject.perform
+ end
+
+ it 'uses the deleted user as author' do
+ expect(UserMailer).to receive(:work_package_added)
+ .with(anything, anything, DeletedUser.first)
+
+ subject.perform
+ end
+ end
+
+ context 'outdated journal' do
+ before do
+ # make sure there is a later journal, that supersedes the original one
+ work_package.subject = 'changed subject'
+ work_package.save!
+ end
+
+ it 'raises an error' do
+ expect { subject.perform }.to raise_error('aggregated journal got outdated')
+ end
+ end
+
+ context 'update journal' do
+ let(:journal) { work_package.journals.last }
+
+ before do
+ work_package.add_journal(FactoryGirl.create(:user), 'a comment')
+ work_package.save!
+ end
+
+ it 'sends an update mail' do
+ expect(UserMailer).to receive(:work_package_updated)
+ subject.perform
+ end
+
+ it 'sends a mail for the aggregated journal' do
+ expected = Journal::AggregatedJournal.aggregated_journals(journable: work_package).last
+ expect(UserMailer).to receive(:work_package_updated) do |_recipient, journal, _author|
+ expect(journal.id).to eq expected.id
+ expect(journal.notes_id).to eq expected.notes_id
+
+ double('mail', deliver: nil)
+ end
+ subject.perform
+ end
+ end
+
+ describe 'impersonation' do
+ describe 'the recipient should become the current user during mail creation' do
+ before do
+ expect(UserMailer).to receive(:work_package_added) do
+ expect(User.current).to eql(recipient)
+ double('mail', deliver: nil)
+ end
+ end
+
+ it { subject.perform }
+ end
+
+ context 'for a known current user' do
+ let(:current_user) { FactoryGirl.create(:user) }
+
+ it 'resets to the previous current user after running' do
+ User.current = current_user
+ subject.perform
+ expect(User.current).to eql(current_user)
+ end
+ end
+ end
+
+ describe 'exceptions' do
+ describe 'exceptions should be raised' do
+ before do
+ mail = double('mail')
+ allow(mail).to receive(:deliver).and_raise(SocketError)
+ expect(UserMailer).to receive(:work_package_added).and_return(mail)
+ end
+
+ it 'raises the error' do
+ expect { subject.perform }.to raise_error(SocketError)
+ end
+ end
+ end
+end
diff --git a/spec/workers/mail_notification_jobs/deliver_work_package_updated_spec.rb b/spec/workers/mail_notification_jobs/deliver_work_package_updated_spec.rb
deleted file mode 100644
index bd9e0d162c9..00000000000
--- a/spec/workers/mail_notification_jobs/deliver_work_package_updated_spec.rb
+++ /dev/null
@@ -1,66 +0,0 @@
-#-- encoding: UTF-8
-#-- copyright
-# OpenProject is a project management system.
-# Copyright (C) 2012-2015 the OpenProject Foundation (OPF)
-#
-# This program is free software; you can redistribute it and/or
-# modify it under the terms of the GNU General Public License version 3.
-#
-# OpenProject is a fork of ChiliProject, which is a fork of Redmine. The copyright follows:
-# Copyright (C) 2006-2013 Jean-Philippe Lang
-# Copyright (C) 2010-2013 the ChiliProject Team
-#
-# This program is free software; you can redistribute it and/or
-# modify it under the terms of the GNU General Public License
-# as published by the Free Software Foundation; either version 2
-# of the License, or (at your option) any later version.
-#
-# This program is distributed in the hope that it will be useful,
-# but WITHOUT ANY WARRANTY; without even the implied warranty of
-# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
-# GNU General Public License for more details.
-#
-# You should have received a copy of the GNU General Public License
-# along with this program; if not, write to the Free Software
-# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
-#
-# See doc/COPYRIGHT.rdoc for more details.
-#++
-
-require 'spec_helper'
-require 'workers/mail_notification_jobs/shared_examples'
-
-describe DeliverWorkPackageUpdatedJob, type: :model do
- let(:work_package) do
- FactoryGirl.create(:work_package).tap do |wp|
- wp.subject = mail_subject
- wp.save # create journal
- end
- end
-
- let(:current_user) { FactoryGirl.create :user }
- let(:journal) { work_package.journals.last }
- let(:job) { DeliverWorkPackageUpdatedJob.new user.id, journal.id, current_user.id }
-
- it_behaves_like 'a mail notification job' do
- context 'with journal not found' do
- let(:mail_subject) { 'no journal found! :/' }
-
- before do
- journal.destroy
- end
-
- it_behaves_like 'job cannot find record'
- end
-
- context 'with current user not found' do
- let(:mail_subject) { 'current user not found! :x' }
-
- before do
- current_user.destroy
- end
-
- it_behaves_like 'job cannot find record'
- end
- end
-end
diff --git a/spec/workers/mail_notification_jobs/enqueue_work_package_notification_job_spec.rb b/spec/workers/mail_notification_jobs/enqueue_work_package_notification_job_spec.rb
new file mode 100644
index 00000000000..d8195c9b1dd
--- /dev/null
+++ b/spec/workers/mail_notification_jobs/enqueue_work_package_notification_job_spec.rb
@@ -0,0 +1,216 @@
+#-- encoding: UTF-8
+#-- copyright
+# OpenProject is a project management system.
+# Copyright (C) 2012-2015 the OpenProject Foundation (OPF)
+#
+# This program is free software; you can redistribute it and/or
+# modify it under the terms of the GNU General Public License version 3.
+#
+# OpenProject is a fork of ChiliProject, which is a fork of Redmine. The copyright follows:
+# Copyright (C) 2006-2013 Jean-Philippe Lang
+# Copyright (C) 2010-2013 the ChiliProject Team
+#
+# This program is free software; you can redistribute it and/or
+# modify it under the terms of the GNU General Public License
+# as published by the Free Software Foundation; either version 2
+# of the License, or (at your option) any later version.
+#
+# This program is distributed in the hope that it will be useful,
+# but WITHOUT ANY WARRANTY; without even the implied warranty of
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+# GNU General Public License for more details.
+#
+# You should have received a copy of the GNU General Public License
+# along with this program; if not, write to the Free Software
+# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
+#
+# See doc/COPYRIGHT.rdoc for more details.
+#++
+
+require 'spec_helper'
+
+describe EnqueueWorkPackageNotificationJob, type: :model do
+ let(:project) { FactoryGirl.create(:project) }
+ let(:role) { FactoryGirl.create(:role, permissions: [:view_work_packages]) }
+ let(:recipient) {
+ FactoryGirl.create(:user, member_in_project: project, member_through_role: role)
+ }
+ let(:author) { FactoryGirl.create(:user) }
+ let(:work_package) {
+ FactoryGirl.create(:work_package,
+ project: project,
+ author: author,
+ assigned_to: recipient)
+ }
+ let(:journal) { work_package.journals.first }
+ subject { described_class.new(journal.id, author.id) }
+
+ before do
+ # make sure no other calls are made due to WP creation/update
+ allow(OpenProject::Notifications).to receive(:send) # ... and do nothing
+ end
+
+ it 'sends a mail' do
+ expect(Delayed::Job).to receive(:enqueue).with(an_instance_of DeliverWorkPackageNotificationJob)
+ subject.perform
+ end
+
+ context 'non-existant journal' do
+ before do
+ journal.destroy
+ end
+
+ it 'sends no mail' do
+ expect(Delayed::Job).not_to receive(:enqueue)
+ subject.perform
+ end
+ end
+
+ context 'non-existant author' do
+ before do
+ author.destroy
+ end
+
+ it 'sends a mail' do
+ expect(Delayed::Job).to receive(:enqueue)
+ .with(an_instance_of DeliverWorkPackageNotificationJob)
+ subject.perform
+ end
+ end
+
+ context 'outdated journal' do
+ before do
+ # make sure there is a later journal, that supersedes the original one
+ work_package.subject = 'changed subject'
+ work_package.save!
+ end
+
+ it 'does not send any mails' do
+ expect(Delayed::Job).not_to receive(:enqueue)
+ subject.perform
+ end
+ end
+
+ describe 'mail suppressing aggregation' do
+ # business logic of whether to send or not to send a mail is mainly driven by the presence
+ # of an aggregated journal. However, there is an edge case that could lead to a notification
+ # getting lost. Sadly this is very implementation specific, so I'll describe it:
+ # Journal 1: comment
+ # Journal 2: change (this can also be multiple journals)
+ # Journal 3: comment
+ #
+ # The Job for the first journal will not send any mail, because Journal 2 supersedes it.
+ # However, after adding Journal 3, the aggregation will look like (1), (2, 3). Therefore the
+ # job for Journal 2 will not send a notification. Finally the job for Journal 3 will send a
+ # notification, but only containing the changes of 2 and 3. The comment of journal 1 is lost.
+ # Therefore two things have to happen:
+ # - someone needs to send notifications for the hidden journal
+ # (done by JournalNotificationMailer)
+ # - in case a journal is hidden, its Job is not allowed to enqueue a mail for it
+ # (because someone else will do it on behalf)
+ # This is important since late exec of a Job might cause it to _not_ skip notifications
+
+ before do
+ change = { subject: 'new subject' }
+ note = { notes: 'a comment' }
+
+ expect(work_package.update_by!(author, note)).to be_truthy
+ work_package.reload
+ expect(work_package.update_by!(author, change)).to be_truthy
+ work_package.reload
+ expect(work_package.update_by!(author, note)).to be_truthy
+ end
+
+ let(:timeout) { Setting.journal_aggregation_time_minutes.to_i.minutes }
+ let(:journal_1) { work_package.journals[1] }
+ let(:journal_2) { work_package.journals[2] }
+ let(:journal_3) { work_package.journals[3] }
+
+ context 'all changes happen within the timeout of journal 1' do
+ # The job for 1 will know, that Journal 3 took its addition.
+ # The job for 2 will know, that it has become part of Journal 3.
+ # -> no special behaviour required
+
+ it 'Job 1 sends one mail for journal 1' do
+ expect(Delayed::Job).to receive(:enqueue)
+ .with(an_instance_of DeliverWorkPackageNotificationJob)
+ .once
+ described_class.new(journal_1.id, author.id).perform
+ end
+
+ it 'Job 2 sends no mails' do
+ expect(Delayed::Job).not_to receive(:enqueue)
+ described_class.new(journal_2.id, author.id).perform
+ end
+
+ it 'Job 3 sends one mail for journal (2,3)' do
+ expect(Delayed::Job).to receive(:enqueue)
+ .with(an_instance_of DeliverWorkPackageNotificationJob)
+ .once
+ described_class.new(journal_3.id, author.id).perform
+ end
+ end
+
+ context 'journal 3 created after timeout of 1, but inside of timeout for 2' do
+ # Job 1 will not send a mail because it does not know about journal 3
+ # (thinking 2 will take its mail)
+ # The mail of Job 1 is taken over by the JournalNotificationMailer for Journal 3
+ # Even if Job 1 knew of journal 3 (due to late execution), it was not allowed to send a mail
+ # (that would cause a duplicate mail delivery)
+
+ before do
+ journal_2.created_at = journal_1.created_at + (timeout / 2)
+ journal_3.created_at = journal_1.created_at + timeout + 5.seconds
+ journal_2.save!
+ journal_3.save!
+ end
+
+ it 'Job 1 sends no mails' do
+ expect(Delayed::Job).not_to receive(:enqueue)
+ described_class.new(journal_1.id, author.id).perform
+ end
+
+ it 'Job 2 sends no mails' do
+ expect(Delayed::Job).not_to receive(:enqueue)
+ described_class.new(journal_2.id, author.id).perform
+ end
+
+ it 'Job 3 sends one mail for (2,3)' do
+ expect(Delayed::Job).to receive(:enqueue)
+ .with(an_instance_of DeliverWorkPackageNotificationJob)
+ .once
+ described_class.new(journal_3.id, author.id).perform
+ end
+ end
+
+ context 'journal 3 created after timeout of 1 and 2' do
+ # This is a normal case again, ensuring nobody takes responsiblity when not neccessary.
+
+ before do
+ journal_2.created_at = journal_1.created_at + (timeout / 2)
+ journal_3.created_at = journal_2.created_at + timeout + 5.seconds
+ journal_2.save!
+ journal_3.save!
+ end
+
+ it 'Job 1 sends no mails' do
+ expect(Delayed::Job).not_to receive(:enqueue)
+ described_class.new(journal_1.id, author.id).perform
+ end
+
+ it 'Job 2 sends one mail for journal (1, 2)' do
+ expect(Delayed::Job).to receive(:enqueue)
+ .with(an_instance_of DeliverWorkPackageNotificationJob)
+ .once
+ described_class.new(journal_2.id, author.id).perform
+ end
+
+ it 'Job 3 sends one mail for journal 3' do
+ expect(Delayed::Job).to receive(:enqueue)
+ .with(an_instance_of DeliverWorkPackageNotificationJob)
+ .once
+ described_class.new(journal_3.id, author.id).perform
+ end
+ end
+ end
+end
diff --git a/spec/workers/mail_notification_jobs/mail_notification_job_spec.rb b/spec/workers/mail_notification_jobs/mail_notification_job_spec.rb
deleted file mode 100644
index 89bf602ec2f..00000000000
--- a/spec/workers/mail_notification_jobs/mail_notification_job_spec.rb
+++ /dev/null
@@ -1,70 +0,0 @@
-#-- encoding: UTF-8
-#-- copyright
-# OpenProject is a project management system.
-# Copyright (C) 2012-2015 the OpenProject Foundation (OPF)
-#
-# This program is free software; you can redistribute it and/or
-# modify it under the terms of the GNU General Public License version 3.
-#
-# OpenProject is a fork of ChiliProject, which is a fork of Redmine. The copyright follows:
-# Copyright (C) 2006-2013 Jean-Philippe Lang
-# Copyright (C) 2010-2013 the ChiliProject Team
-#
-# This program is free software; you can redistribute it and/or
-# modify it under the terms of the GNU General Public License
-# as published by the Free Software Foundation; either version 2
-# of the License, or (at your option) any later version.
-#
-# This program is distributed in the hope that it will be useful,
-# but WITHOUT ANY WARRANTY; without even the implied warranty of
-# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
-# GNU General Public License for more details.
-#
-# You should have received a copy of the GNU General Public License
-# along with this program; if not, write to the Free Software
-# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
-#
-# See doc/COPYRIGHT.rdoc for more details.
-#++
-
-require 'spec_helper'
-
-describe MailNotificationJob, type: :model do
- class StubNoticationJob < MailNotificationJob
- def initialize(recipient_id, author_id, mail_callback)
- super(recipient_id, author_id)
- @mail_callback = mail_callback
- end
-
- def notification_mail
- @mail_callback.call
- end
- end
-
- let(:recipient) { FactoryGirl.create(:user) }
- let(:author) { FactoryGirl.create(:user) }
- let(:mail) { double('a mail', deliver: nil) }
- let(:mail_callback) { -> { mail } }
- subject { StubNoticationJob.new(recipient.id, author.id, mail_callback) }
-
- describe 'the recipient should become the current user during mail creation' do
- let(:mail_callback) {
- -> {
- expect(User.current).to eql(recipient)
- mail
- }
- }
-
- it { subject.perform }
- end
-
- context 'for a known current user' do
- let(:current_user) { FactoryGirl.create(:user) }
-
- it 'resets to the previous current user after running' do
- User.current = current_user
- subject.perform
- expect(User.current).to eql(current_user)
- end
- end
-end
diff --git a/spec/workers/mail_notification_jobs/shared_examples.rb b/spec/workers/mail_notification_jobs/shared_examples.rb
deleted file mode 100644
index 519667f6b7a..00000000000
--- a/spec/workers/mail_notification_jobs/shared_examples.rb
+++ /dev/null
@@ -1,100 +0,0 @@
-#-- encoding: UTF-8
-#-- copyright
-# OpenProject is a project management system.
-# Copyright (C) 2012-2015 the OpenProject Foundation (OPF)
-#
-# This program is free software; you can redistribute it and/or
-# modify it under the terms of the GNU General Public License version 3.
-#
-# OpenProject is a fork of ChiliProject, which is a fork of Redmine. The copyright follows:
-# Copyright (C) 2006-2013 Jean-Philippe Lang
-# Copyright (C) 2010-2013 the ChiliProject Team
-#
-# This program is free software; you can redistribute it and/or
-# modify it under the terms of the GNU General Public License
-# as published by the Free Software Foundation; either version 2
-# of the License, or (at your option) any later version.
-#
-# This program is distributed in the hope that it will be useful,
-# but WITHOUT ANY WARRANTY; without even the implied warranty of
-# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
-# GNU General Public License for more details.
-#
-# You should have received a copy of the GNU General Public License
-# along with this program; if not, write to the Free Software
-# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
-#
-# See doc/COPYRIGHT.rdoc for more details.
-#++
-
-require 'spec_helper'
-
-##
-# Shared example expecting #mail_subject and #job to be defined where
-# the former is looked for in the sent email's subject and the latter is
-# the delayed job to be performed.
-# Tests that mail notifications are sent in successful cases,
-# that none are sent but the job finishes nontheless in cases were necessary
-# records are missing (e.g. the user to be notified), and that jobs with any other
-# sort of error fail as expected in order to be rescheduled.
-shared_examples 'a mail notification job' do
- let!(:user) { FactoryGirl.create :user } # user to be notified
-
- context 'with all records found' do
- let(:mail_subject) { 'all records found!' }
-
- before do
- job.perform
- end
-
- it 'sends an email' do
- mail = ActionMailer::Base.deliveries.detect { |m| m.subject.include? mail_subject }
-
- expect(mail).to be_present
- end
- end
-
- shared_examples 'job cannot find record' do
- it 'does not send an email' do
- job.perform
- mail = ActionMailer::Base.deliveries.detect { |m| m.subject.include? mail_subject }
-
- expect(mail).not_to be_present
- end
-
- it 'does not raise an error but fails silently' do
- expect { job.perform }.not_to raise_error
- end
-
- context 'raising exceptions' do
- before { MailNotificationJob.raise_exceptions = true }
- after { MailNotificationJob.raise_exceptions = false }
-
- it 'raises an error' do
- expect { job.perform }.to raise_error(ActiveRecord::RecordNotFound)
- end
- end
- end
-
- context 'with user not found' do
- let(:mail_subject) { 'no user found :(' }
-
- before do
- user.destroy
- end
-
- it_behaves_like 'job cannot find record'
- end
-
- context 'with unexpected error' do
- let(:mail_subject) { "don't care" }
-
- before do
- expect_any_instance_of(Mail::Message).to receive(:deliver).and_raise(SocketError)
- end
-
- it 'raises said error' do
- expect { job.perform }.to raise_error(SocketError)
- end
- end
-end