diff --git a/app/assets/images/icons.svg b/app/assets/images/icons.svg index 55e3a042d..2b0c9a41b 100644 --- a/app/assets/images/icons.svg +++ b/app/assets/images/icons.svg @@ -459,6 +459,13 @@ + + + + + + + diff --git a/app/assets/javascripts/application-legacy.js b/app/assets/javascripts/application-legacy.js index 265ac39c6..286e3e2e6 100644 --- a/app/assets/javascripts/application-legacy.js +++ b/app/assets/javascripts/application-legacy.js @@ -1222,8 +1222,8 @@ function setupWikiTableSortableHeader() { }); } -function setupHoverTooltips() { - $("[title]:not(.no-tooltip)").tooltip({ +function setupHoverTooltips(container) { + $(container || 'body').find("[title]:not(.no-tooltip)").tooltip({ show: { delay: 400 }, @@ -1233,7 +1233,9 @@ function setupHoverTooltips() { } }); } - +function removeHoverTooltips(container) { + $(container || 'body').find("[title]:not(.no-tooltip)").tooltip('destroy') +} $(function() { setupHoverTooltips(); }); function inlineAutoComplete(element) { diff --git a/app/assets/stylesheets/application.css b/app/assets/stylesheets/application.css index d6288ad4f..1556bf82a 100644 --- a/app/assets/stylesheets/application.css +++ b/app/assets/stylesheets/application.css @@ -2052,6 +2052,32 @@ color: #555; text-shadow: 1px 1px 0 #fff; img.filecontent.image {background-image: url(/transparent.png);} +/* Reaction styles */ +.reaction-button.reacted .icon-svg { + fill: #126fa7; + stroke: none; +} +.reaction-button.reacted:hover .icon-svg { + fill: #c61a1a; +} +.reaction-button .icon-label { + margin-left: 3px; + margin-bottom: -1px; +} +div.issue.details .reaction { + float: right; + font-size: 0.9em; + margin-top: 0.5em; +} +div.message .reaction { + float: right; + font-size: 0.9em; +} +div.news .reaction { + float: right; + font-size: 0.9em; +} + /* Custom JQuery styles */ .ui-autocomplete, .ui-menu { border-radius: 2px; diff --git a/app/controllers/messages_controller.rb b/app/controllers/messages_controller.rb index 22daf9f90..329e22530 100644 --- a/app/controllers/messages_controller.rb +++ b/app/controllers/messages_controller.rb @@ -49,7 +49,7 @@ class MessagesController < ApplicationController reorder("#{Message.table_name}.created_on ASC, #{Message.table_name}.id ASC"). limit(@reply_pages.per_page). offset(@reply_pages.offset). - to_a + load_with_reactions @reply = Message.new(:subject => "RE: #{@message.subject}") render :action => "show", :layout => false if request.xhr? diff --git a/app/controllers/news_controller.rb b/app/controllers/news_controller.rb index 06240e359..139c94c7b 100644 --- a/app/controllers/news_controller.rb +++ b/app/controllers/news_controller.rb @@ -67,7 +67,7 @@ class NewsController < ApplicationController end def show - @comments = @news.comments.to_a + @comments = @news.comments.load_with_reactions @comments.reverse! if User.current.wants_comments_in_reverse_order? end diff --git a/app/controllers/reactions_controller.rb b/app/controllers/reactions_controller.rb new file mode 100644 index 000000000..a7003afc3 --- /dev/null +++ b/app/controllers/reactions_controller.rb @@ -0,0 +1,55 @@ +# frozen_string_literal: true + +# Redmine - project management software +# Copyright (C) 2006- Jean-Philippe Lang +# +# 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. + +class ReactionsController < ApplicationController + before_action :check_enabled + + before_action :require_login + before_action :set_object, :authorize_reactable + + def create + @reaction = @object.reactions.find_or_create_by!(user: User.current) + end + + def destroy + @reaction = @object.reactions.by(User.current).find_by(id: params[:id]) + @reaction&.destroy + end + + private + + def check_enabled + render_403 unless Setting.reactions_enabled? + end + + def set_object + object_type = params[:object_type] + + unless Redmine::Reaction::REACTABLE_TYPES.include?(object_type) + render_403 + return + end + + @object = object_type.constantize.find(params[:object_id]) + end + + def authorize_reactable + render_403 unless @object.visible?(User.current) + end +end diff --git a/app/helpers/issues_helper.rb b/app/helpers/issues_helper.rb index 6586a1b7e..ce3607a5d 100644 --- a/app/helpers/issues_helper.rb +++ b/app/helpers/issues_helper.rb @@ -22,6 +22,7 @@ module IssuesHelper include Redmine::Export::PDF::IssuesPdfHelper include IssueStatusesHelper include QueriesHelper + include ReactionsHelper def issue_list(issues, &) ancestors = [] diff --git a/app/helpers/journals_helper.rb b/app/helpers/journals_helper.rb index 6c22fc4ca..66f81033e 100644 --- a/app/helpers/journals_helper.rb +++ b/app/helpers/journals_helper.rb @@ -19,6 +19,7 @@ module JournalsHelper include Redmine::QuoteReply::Helper + include ReactionsHelper # Returns the attachments of a journal that are displayed as thumbnails def journal_thumbnail_attachments(journal) @@ -41,6 +42,8 @@ module JournalsHelper end if journal.notes.present? + links << reaction_button(journal) + if options[:reply_links] url = quoted_issue_path(issue, :journal_id => journal, :journal_indice => indice) links << quote_reply(url, "#journal-#{journal.id}-notes", icon_only: true) diff --git a/app/helpers/messages_helper.rb b/app/helpers/messages_helper.rb index fd9ba3bcb..92f788d0c 100644 --- a/app/helpers/messages_helper.rb +++ b/app/helpers/messages_helper.rb @@ -19,4 +19,5 @@ module MessagesHelper include Redmine::QuoteReply::Helper + include ReactionsHelper end diff --git a/app/helpers/news_helper.rb b/app/helpers/news_helper.rb index a5c50fdfd..cd7b6734a 100644 --- a/app/helpers/news_helper.rb +++ b/app/helpers/news_helper.rb @@ -18,4 +18,5 @@ # Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. module NewsHelper + include ReactionsHelper end diff --git a/app/helpers/reactions_helper.rb b/app/helpers/reactions_helper.rb new file mode 100644 index 000000000..bb1a97f90 --- /dev/null +++ b/app/helpers/reactions_helper.rb @@ -0,0 +1,96 @@ +# frozen_string_literal: true + +# Redmine - project management software +# Copyright (C) 2006- Jean-Philippe Lang +# +# 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. + +module ReactionsHelper + def reaction_button(object, reaction = nil) + return unless Setting.reactions_enabled? && object.visible?(User.current) + + reaction ||= object.reaction_by(User.current) + + count = object.reaction_count + user_names = object.reaction_user_names + + tooltip = build_reaction_tooltip(user_names, count) + + if User.current.logged? + if reaction&.persisted? + reaction_button_reacted(object, reaction, count, tooltip) + else + reaction_button_not_reacted(object, count, tooltip) + end + else + reaction_button_readonly(object, count, tooltip) + end + end + + def reaction_id_for(object) + dom_id(object, :reaction) + end + + private + + def reaction_button_reacted(object, reaction, count, tooltip) + reaction_button_wrapper object do + link_to( + sprite_icon('thumb-up-filled', count), + reaction_path(reaction, object_type: object.class.name, object_id: object), + remote: true, method: :delete, + class: ['icon', 'reaction-button', 'reacted'], + title: tooltip + ) + end + end + + def reaction_button_not_reacted(object, count, tooltip) + reaction_button_wrapper object do + link_to( + sprite_icon('thumb-up', count), + reactions_path(object_type: object.class.name, object_id: object), + remote: true, method: :post, + class: 'icon reaction-button', + title: tooltip + ) + end + end + + def reaction_button_readonly(object, count, tooltip) + reaction_button_wrapper object do + tag.span(class: 'icon reaction-button', title: tooltip) do + sprite_icon('thumb-up', count) + end + end + end + + def reaction_button_wrapper(object, &) + tag.span(data: { 'reaction-button-id': reaction_id_for(object) }, &) + end + + def build_reaction_tooltip(user_names, count) + return if count.zero? + + display_user_names = user_names.dup + + if count > Redmine::Reaction::DISPLAY_REACTION_USERS_LIMIT + others = count - Redmine::Reaction::DISPLAY_REACTION_USERS_LIMIT + display_user_names << I18n.t(:reaction_text_x_other_users, count: others) + end + + display_user_names.to_sentence(locale: I18n.locale) + end +end diff --git a/app/models/comment.rb b/app/models/comment.rb index 79eb59748..483073c5b 100644 --- a/app/models/comment.rb +++ b/app/models/comment.rb @@ -19,6 +19,8 @@ class Comment < ApplicationRecord include Redmine::SafeAttributes + include Redmine::Reaction::Reactable + belongs_to :commented, :polymorphic => true, :counter_cache => true belongs_to :author, :class_name => 'User' @@ -28,6 +30,8 @@ class Comment < ApplicationRecord safe_attributes 'comments' + delegate :visible?, to: :commented + def comments=(arg) self.content = arg end diff --git a/app/models/issue.rb b/app/models/issue.rb index ac3b40bf1..4c8a4b1c9 100644 --- a/app/models/issue.rb +++ b/app/models/issue.rb @@ -25,6 +25,7 @@ class Issue < ApplicationRecord before_validation :clear_disabled_fields before_save :set_parent_id include Redmine::NestedSet::IssueNestedSet + include Redmine::Reaction::Reactable belongs_to :project belongs_to :tracker @@ -916,7 +917,8 @@ class Issue < ApplicationRecord result = journals. preload(:details). preload(:user => :email_address). - reorder(:created_on, :id).to_a + reorder(:created_on, :id). + load_with_reactions result.each_with_index {|j, i| j.indice = i + 1} diff --git a/app/models/journal.rb b/app/models/journal.rb index 039b182e2..12f2beec8 100644 --- a/app/models/journal.rb +++ b/app/models/journal.rb @@ -19,6 +19,7 @@ class Journal < ApplicationRecord include Redmine::SafeAttributes + include Redmine::Reaction::Reactable belongs_to :journalized, :polymorphic => true # added as a quick fix to allow eager loading of the polymorphic association diff --git a/app/models/message.rb b/app/models/message.rb index c7f78d2d9..9ac88c7d1 100644 --- a/app/models/message.rb +++ b/app/models/message.rb @@ -19,6 +19,8 @@ class Message < ApplicationRecord include Redmine::SafeAttributes + include Redmine::Reaction::Reactable + belongs_to :board belongs_to :author, :class_name => 'User' acts_as_tree :counter_cache => :replies_count, :order => "#{Message.table_name}.created_on ASC" diff --git a/app/models/news.rb b/app/models/news.rb index 40cd63db9..174e4c5ac 100644 --- a/app/models/news.rb +++ b/app/models/news.rb @@ -19,6 +19,8 @@ class News < ApplicationRecord include Redmine::SafeAttributes + include Redmine::Reaction::Reactable + belongs_to :project belongs_to :author, :class_name => 'User' has_many :comments, lambda {order("created_on")}, :as => :commented, :dependent => :delete_all diff --git a/app/models/reaction.rb b/app/models/reaction.rb new file mode 100644 index 000000000..b19e7915a --- /dev/null +++ b/app/models/reaction.rb @@ -0,0 +1,54 @@ +# frozen_string_literal: true + +# Redmine - project management software +# Copyright (C) 2006- Jean-Philippe Lang +# +# 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. + +class Reaction < ApplicationRecord + belongs_to :reactable, polymorphic: true + belongs_to :user + + validates :reactable_type, inclusion: { in: Redmine::Reaction::REACTABLE_TYPES } + + scope :by, ->(user) { where(user: user) } + + # Returns a mapping of reactable IDs to an array of user names + # + # Returns: + # { + # 1 => ["Alice", "Bob"], + # 2 => ["Charlie"], + # ... + # } + def self.users_map_for_reactables(reactable_type, reactable_ids) + reactions = preload(:user) + .select(:reactable_id, :user_id) + .where(reactable_type: reactable_type, reactable_id: reactable_ids) + .order(id: :desc) + + reactable_user_pairs = reactions.map do |reaction| + [reaction.reactable_id, reaction.user.name] + end + + # Group by reactable_id and transform values to extract only user name + # [[1, "Alice"], [1, "Bob"], [2, "Charlie"], ...] + # => + # { 1 => ["Alice", "Bob"], 2 => ["Charlie"], ...} + reactable_user_pairs + .group_by(&:first) + .transform_values { |pairs| pairs.map(&:last) } + end +end diff --git a/app/models/user.rb b/app/models/user.rb index 51ebf25f6..1c40ccf60 100644 --- a/app/models/user.rb +++ b/app/models/user.rb @@ -93,6 +93,7 @@ class User < Principal has_one :api_token, lambda {where "#{table.name}.action='api'"}, :class_name => 'Token' has_one :email_address, lambda {where :is_default => true}, :autosave => true has_many :email_addresses, :dependent => :delete_all + has_many :reactions, dependent: :delete_all belongs_to :auth_source scope :logged, lambda {where("#{User.table_name}.status <> #{STATUS_ANONYMOUS}")} diff --git a/app/views/issues/show.html.erb b/app/views/issues/show.html.erb index 8f732032a..37fee024b 100644 --- a/app/views/issues/show.html.erb +++ b/app/views/issues/show.html.erb @@ -39,6 +39,10 @@
<%= render_issue_subject_with_tree(@issue) %> +
+ +
+ <%= reaction_button @issue %>

<%= authoring @issue.created_on, @issue.author %>. diff --git a/app/views/messages/show.html.erb b/app/views/messages/show.html.erb index b265cc962..b62709afa 100644 --- a/app/views/messages/show.html.erb +++ b/app/views/messages/show.html.erb @@ -27,6 +27,9 @@

<%= avatar(@topic.author) %><%= @topic.subject %>

+
+ <%= reaction_button @topic %> +

<%= authoring @topic.created_on, @topic.author %>

<%= textilizable(@topic, :content) %> @@ -44,6 +47,7 @@ <% @replies.each do |message| %>
">
+ <%= reaction_button message %> <%= quote_reply( url_for(:action => 'quote', :id => message, :format => 'js'), "#message-#{message.id} .wiki", diff --git a/app/views/news/show.html.erb b/app/views/news/show.html.erb index d07a09eb7..601f12072 100644 --- a/app/views/news/show.html.erb +++ b/app/views/news/show.html.erb @@ -22,12 +22,17 @@
<% end %> -

<% unless @news.summary.blank? %><%= @news.summary %>
<% end %> -<%= authoring @news.created_on, @news.author %>

-
-<%= textilizable(@news, :description) %> +
+
+ <%= reaction_button @news %> +
+

<% unless @news.summary.blank? %><%= @news.summary %>
<% end %> + <%= authoring @news.created_on, @news.author %>

+
+ <%= textilizable(@news, :description) %> +
+ <%= link_to_attachments @news %>
-<%= link_to_attachments @news %>
@@ -38,6 +43,7 @@ <% @comments.each do |comment| %> <% next if comment.new_record? %>
+ <%= reaction_button comment %> <%= link_to_if_authorized sprite_icon('del', l(:button_delete)), { :controller => 'comments', :action => 'destroy', :id => @news, :comment_id => comment}, :data => {:confirm => l(:text_are_you_sure)}, :method => :delete, :title => l(:button_delete), diff --git a/app/views/reactions/_replace_button.js.erb b/app/views/reactions/_replace_button.js.erb new file mode 100644 index 000000000..642c0581d --- /dev/null +++ b/app/views/reactions/_replace_button.js.erb @@ -0,0 +1,7 @@ +(() => { + const button = $('[data-reaction-button-id=<%= reaction_id_for @object %>]'); + + removeHoverTooltips(button); + button.html($('<%=j reaction_button @object, @reaction %>').children()); + setupHoverTooltips(button); +})(); diff --git a/app/views/reactions/create.js.erb b/app/views/reactions/create.js.erb new file mode 100644 index 000000000..20f3cc7ed --- /dev/null +++ b/app/views/reactions/create.js.erb @@ -0,0 +1 @@ +<%= render 'replace_button' %> diff --git a/app/views/reactions/destroy.js.erb b/app/views/reactions/destroy.js.erb new file mode 100644 index 000000000..20f3cc7ed --- /dev/null +++ b/app/views/reactions/destroy.js.erb @@ -0,0 +1 @@ +<%= render 'replace_button' %> diff --git a/app/views/settings/_general.html.erb b/app/views/settings/_general.html.erb index 043067f18..ad15a5881 100644 --- a/app/views/settings/_general.html.erb +++ b/app/views/settings/_general.html.erb @@ -37,6 +37,13 @@

<%= setting_text_field :feeds_limit, :size => 6 %>

+

+ <%= setting_check_box :reactions_enabled %> + + <%= l(:reaction_text_enabling_reactions) %> + +

+ <%= call_hook(:view_settings_general_form) %>
diff --git a/config/icon_source.yml b/config/icon_source.yml index dc1803cdc..6fc7c5567 100644 --- a/config/icon_source.yml +++ b/config/icon_source.yml @@ -221,4 +221,9 @@ - name: unwatch svg: eye-off - name: copy-pre-content - svg: clipboard \ No newline at end of file + svg: clipboard +- name: thumb-up + svg: thumb-up +- name: thumb-up-filled + svg: thumb-up + style: filled diff --git a/config/locales/en.yml b/config/locales/en.yml index 819846e1a..566eab262 100644 --- a/config/locales/en.yml +++ b/config/locales/en.yml @@ -528,6 +528,7 @@ en: setting_twofa: Two-factor authentication setting_related_issues_default_columns: Related and sub issues list defaults setting_display_related_issues_table_headers: Show table headers + setting_reactions_enabled: Enable reactions permission_add_project: Create project permission_add_subprojects: Create subprojects @@ -1432,3 +1433,8 @@ en: text_project_destroy_enter_identifier: "To confirm, please enter the project's identifier (%{identifier}) below." field_name_or_email_or_login: Name, email or login setting_wiki_tablesort_enabled: Javascript based table sorting in wiki content + + reaction_text_enabling_reactions: "This enables reactions in issues, forums, and news." + reaction_text_x_other_users: + one: "1 other" + other: "%{count} others" diff --git a/config/locales/ja.yml b/config/locales/ja.yml index d72d136ec..a1bce32d2 100644 --- a/config/locales/ja.yml +++ b/config/locales/ja.yml @@ -1457,3 +1457,8 @@ ja: setting_related_issues_default_columns: 関連するチケットと子チケットの一覧で表示する項目 setting_display_related_issues_table_headers: テーブルヘッダを表示 error_can_not_remove_role_reason_members_html: "

以下のプロジェクトにこのロールのメンバーがいます:
%{projects}

" + setting_reactions_enabled: リアクション機能を有効にする + reaction_text_enabling_reactions: "チケット、フォーラム、ニュースでリアクション機能を有効にします。" + reaction_text_x_other_users: + one: 他1人 + other: "他%{count}人" diff --git a/config/routes.rb b/config/routes.rb index 89927bee3..20a7e826a 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -61,6 +61,8 @@ Rails.application.routes.draw do end end + resources :reactions, only: [:create, :destroy] + get '/projects/:project_id/issues/gantt', :to => 'gantts#show', :as => 'project_gantt' get '/issues/gantt', :to => 'gantts#show' diff --git a/config/settings.yml b/config/settings.yml index a0c256cdd..cda40fa38 100644 --- a/config/settings.yml +++ b/config/settings.yml @@ -363,3 +363,5 @@ show_status_changes_in_mail_subject: default: 1 wiki_tablesort_enabled: default: 1 +reactions_enabled: + default: 1 diff --git a/db/migrate/20250423065135_create_reactions.rb b/db/migrate/20250423065135_create_reactions.rb new file mode 100644 index 000000000..56f345e1b --- /dev/null +++ b/db/migrate/20250423065135_create_reactions.rb @@ -0,0 +1,11 @@ +class CreateReactions < ActiveRecord::Migration[7.2] + def change + create_table :reactions do |t| + t.references :reactable, polymorphic: true, null: false + t.references :user, null: false + t.timestamps null: false + end + add_index :reactions, [:reactable_type, :reactable_id, :user_id], unique: true + add_index :reactions, [:reactable_type, :reactable_id, :id] + end +end diff --git a/lib/redmine/reaction.rb b/lib/redmine/reaction.rb new file mode 100644 index 000000000..b033f4ace --- /dev/null +++ b/lib/redmine/reaction.rb @@ -0,0 +1,73 @@ +# frozen_string_literal: true + +# Redmine - project management software +# Copyright (C) 2006- Jean-Philippe Lang +# +# 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. + +module Redmine + module Reaction + # Types of objects that can have reactions + REACTABLE_TYPES = %w(Journal Issue Message News Comment) + + # Maximum number of users to display in the reaction button tooltip + DISPLAY_REACTION_USERS_LIMIT = 10 + + module Reactable + extend ActiveSupport::Concern + + included do + has_many :reactions, -> { order(id: :desc) }, as: :reactable, dependent: :delete_all + has_many :reaction_users, through: :reactions, source: :user + + attr_writer :reaction_user_names, :reaction_count + end + + class_methods do + def load_with_reactions + objects = all.to_a + + return objects unless Setting.reactions_enabled? + + object_users_map = ::Reaction.users_map_for_reactables(self.name, objects.map(&:id)) + + objects.each do |object| + all_user_names = object_users_map[object.id] || [] + + object.reaction_count = all_user_names.size + object.reaction_user_names = all_user_names.take(DISPLAY_REACTION_USERS_LIMIT) + end + objects + end + end + + def reaction_user_names + @reaction_user_names || reaction_users.take(DISPLAY_REACTION_USERS_LIMIT).map(&:name) + end + + def reaction_count + @reaction_count || reaction_users.size + end + + def reaction_by(user) + if reactions.loaded? + reactions.find { _1.user_id == user.id } + else + reactions.find_by(user: user) + end + end + end + end +end diff --git a/test/fixtures/reactions.yml b/test/fixtures/reactions.yml new file mode 100644 index 000000000..d8fcbfc1b --- /dev/null +++ b/test/fixtures/reactions.yml @@ -0,0 +1,51 @@ +--- +reaction_001: + id: 1 + reactable_type: Issue + reactable_id: 1 + user_id: 1 +reaction_002: + id: 2 + reactable_type: Issue + reactable_id: 1 + user_id: 2 +reaction_003: + id: 3 + reactable_type: Issue + reactable_id: 1 + user_id: 3 +reaction_004: + id: 4 + reactable_type: Journal + reactable_id: 1 + user_id: 2 +reaction_005: + id: 5 + reactable_type: Issue + reactable_id: 6 + user_id: 2 +reaction_006: + id: 6 + reactable_type: Journal + reactable_id: 4 + user_id: 2 +reaction_007: + id: 7 + reactable_type: News + reactable_id: 1 + user_id: 1 +reaction_008: + id: 8 + reactable_type: Comment + reactable_id: 1 + user_id: 2 +reaction_009: + id: 9 + reactable_type: Message + reactable_id: 7 + user_id: 2 +reaction_010: + id: 10 + reactable_type: News + reactable_id: 3 + user_id: 2 diff --git a/test/functional/issues_controller_test.rb b/test/functional/issues_controller_test.rb index b5180fcff..f8f0f3e7f 100644 --- a/test/functional/issues_controller_test.rb +++ b/test/functional/issues_controller_test.rb @@ -3331,6 +3331,26 @@ class IssuesControllerTest < Redmine::ControllerTest assert_select 'span.badge.badge-private', text: 'Private' end + def test_show_should_display_reactions + @request.session[:user_id] = nil + + get :show, params: { id: 1 } + + assert_response :success + assert_select 'span[data-reaction-button-id=reaction_issue_1] span.reaction-button' do + assert_select 'span.icon-label', '3' + end + assert_select 'span[data-reaction-button-id=reaction_journal_1] span.reaction-button' + assert_select 'span[data-reaction-button-id=reaction_journal_2] span.reaction-button' + + # Should not display reactions when reactions feature is disabled. + Setting.reactions_enabled = false + get :show, params: { id: 1 } + + assert_response :success + assert_select 'span[data-reaction-button-id]', false + end + def test_show_should_not_display_edit_attachment_icon_for_user_without_edit_issue_permission_on_tracker role = Role.find(2) role.set_permission_trackers 'edit_issues', [2, 3] diff --git a/test/functional/messages_controller_test.rb b/test/functional/messages_controller_test.rb index 74b9a3070..bb2894cfa 100644 --- a/test/functional/messages_controller_test.rb +++ b/test/functional/messages_controller_test.rb @@ -123,6 +123,26 @@ class MessagesControllerTest < Redmine::ControllerTest assert_select 'h3', {text: /Watchers \(\d*\)/, count: 0} end + def test_show_should_display_reactions + @request.session[:user_id] = 2 + + get :show, params: { board_id: 1, id: 4 } + + assert_response :success + assert_select 'span[data-reaction-button-id=reaction_message_4] a.reaction-button' do + assert_select 'svg use[href*=thumb-up]' + end + assert_select 'span[data-reaction-button-id=reaction_message_5] a.reaction-button' + assert_select 'span[data-reaction-button-id=reaction_message_6] a.reaction-button' + + # Should not display reactions when reactions feature is disabled. + Setting.reactions_enabled = false + get :show, params: { board_id: 1, id: 4 } + + assert_response :success + assert_select 'span[data-reaction-button-id]', false + end + def test_get_new @request.session[:user_id] = 2 get(:new, :params => {:board_id => 1}) diff --git a/test/functional/news_controller_test.rb b/test/functional/news_controller_test.rb index f1ddfff71..926655dda 100644 --- a/test/functional/news_controller_test.rb +++ b/test/functional/news_controller_test.rb @@ -106,6 +106,21 @@ class NewsControllerTest < Redmine::ControllerTest assert_response :not_found end + def test_show_should_display_reactions + @request.session[:user_id] = 1 + + get :show, params: { id: 1 } + assert_response :success + assert_select 'span[data-reaction-button-id=reaction_news_1] a.reaction-button.reacted' + assert_select 'span[data-reaction-button-id=reaction_comment_1] a.reaction-button' + + # Should not display reactions when reactions feature is disabled. + Setting.reactions_enabled = false + get :show, params: { id: 1 } + assert_response :success + assert_select 'span[data-reaction-button-id]', false + end + def test_get_new_with_project_id @request.session[:user_id] = 2 get(:new, :params => {:project_id => 1}) diff --git a/test/functional/reactions_controller_test.rb b/test/functional/reactions_controller_test.rb new file mode 100644 index 000000000..b6b5b6f2a --- /dev/null +++ b/test/functional/reactions_controller_test.rb @@ -0,0 +1,335 @@ +# frozen_string_literal: true + +# Redmine - project management software +# Copyright (C) 2006- Jean-Philippe Lang +# +# 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. + +require_relative '../test_helper' + +class ReactionsControllerTest < Redmine::ControllerTest + def setup + Setting.reactions_enabled = '1' + # jsmith + @request.session[:user_id] = users(:users_002).id + end + + test 'create for issue' do + issue = issues(:issues_002) + + assert_difference( + ->{ Reaction.count } => 1, + ->{ issue.reactions.by(users(:users_002)).count } => 1 + ) do + post :create, params: { + object_type: 'Issue', + object_id: issue.id + }, xhr: true + end + + assert_response :success + end + + test 'create for journal' do + journal = journals(:journals_005) + + assert_difference( + ->{ Reaction.count } => 1, + ->{ journal.reactions.by(users(:users_002)).count } => 1 + ) do + post :create, params: { + object_type: 'Journal', + object_id: journal.id + }, xhr: true + end + + assert_response :success + end + + test 'create for news' do + news = news(:news_002) + + assert_difference( + ->{ Reaction.count } => 1, + ->{ news.reactions.by(users(:users_002)).count } => 1 + ) do + post :create, params: { + object_type: 'News', + object_id: news.id + }, xhr: true + end + + assert_response :success + end + + test 'create reaction for comment' do + comment = comments(:comments_002) + + assert_difference( + ->{ Reaction.count } => 1, + ->{ comment.reactions.by(users(:users_002)).count } => 1 + ) do + post :create, params: { + object_type: 'Comment', + object_id: comment.id + }, xhr: true + end + + assert_response :success + end + + test 'create for message' do + message = messages(:messages_001) + + assert_difference( + ->{ Reaction.count } => 1, + ->{ message.reactions.by(users(:users_002)).count } => 1 + ) do + post :create, params: { + object_type: 'Message', + object_id: message.id + }, xhr: true + end + + assert_response :success + end + + test 'destroy for issue' do + reaction = reactions(:reaction_005) + + assert_difference 'Reaction.count', -1 do + delete :destroy, params: { + id: reaction.id, + # Issue (id=6) + object_type: reaction.reactable_type, + object_id: reaction.reactable_id + }, xhr: true + end + + assert_response :success + assert_not Reaction.exists?(reaction.id) + end + + test 'destroy for journal' do + reaction = reactions(:reaction_006) + + assert_difference 'Reaction.count', -1 do + delete :destroy, params: { + id: reaction.id, + object_type: reaction.reactable_type, + object_id: reaction.reactable_id + }, xhr: true + end + + assert_response :success + assert_not Reaction.exists?(reaction.id) + end + + test 'destroy for news' do + # For News(id=3) + reaction = reactions(:reaction_010) + + assert_difference 'Reaction.count', -1 do + delete :destroy, params: { + id: reaction.id, + object_type: reaction.reactable_type, + object_id: reaction.reactable_id + }, xhr: true + end + + assert_response :success + assert_not Reaction.exists?(reaction.id) + end + + test 'destroy for comment' do + # For Comment(id=1) + reaction = reactions(:reaction_008) + + assert_difference 'Reaction.count', -1 do + delete :destroy, params: { + id: reaction.id, + object_type: reaction.reactable_type, + object_id: reaction.reactable_id + }, xhr: true + end + + assert_response :success + assert_not Reaction.exists?(reaction.id) + end + + test 'destroy for message' do + reaction = reactions(:reaction_009) + + assert_difference 'Reaction.count', -1 do + delete :destroy, params: { + id: reaction.id, + object_type: reaction.reactable_type, + object_id: reaction.reactable_id + }, xhr: true + end + + assert_response :success + assert_not Reaction.exists?(reaction.id) + end + + test 'create should respond with 403 when feature is disabled' do + Setting.reactions_enabled = '0' + # admin + @request.session[:user_id] = users(:users_001).id + + assert_no_difference 'Reaction.count' do + post :create, params: { + object_type: 'Issue', + object_id: issues(:issues_002).id + }, xhr: true + end + assert_response :forbidden + end + + test 'destroy should respond with 403 when feature is disabled' do + Setting.reactions_enabled = '0' + # admin + @request.session[:user_id] = users(:users_001).id + + reaction = reactions(:reaction_001) + assert_no_difference 'Reaction.count' do + delete :destroy, params: { + id: reaction.id, + object_type: reaction.reactable_type, + object_id: reaction.reactable_id + }, xhr: true + end + assert_response :forbidden + end + + test 'create by anonymous user should respond with 403' do + @request.session[:user_id] = nil + + assert_no_difference 'Reaction.count' do + post :create, params: { + object_type: 'Issue', + # Issue(id=1) is an issue in a public project + object_id: issues(:issues_001).id + }, xhr: true + end + + assert_response :unauthorized + end + + test 'destroy by anonymous user should respond with 401' do + @request.session[:user_id] = nil + + reaction = reactions(:reaction_002) + assert_no_difference 'Reaction.count' do + delete :destroy, params: { + id: reaction.id, + object_type: reaction.reactable_type, + object_id: reaction.reactable_id + }, xhr: true + end + + assert_response :unauthorized + end + + test 'create when reaction already exists should not create a new reaction and succeed' do + assert_no_difference 'Reaction.count' do + post :create, params: { + object_type: 'Comment', + # user(jsmith) has already reacted to Comment(id=1) + object_id: comments(:comments_001).id + }, xhr: true + end + + assert_response :success + end + + test 'destroy another user reaction should not destroy the reaction and succeed' do + # admin user's reaction + reaction = reactions(:reaction_001) + + assert_no_difference 'Reaction.count' do + delete :destroy, params: { + id: reaction.id, + object_type: reaction.reactable_type, + object_id: reaction.reactable_id + }, xhr: true + end + + assert_response :success + end + + test 'destroy nonexistent reaction' do + # For Journal(id=4) + reaction = reactions(:reaction_006) + reaction.destroy! + + assert_not Reaction.exists?(reaction.id) + + assert_no_difference 'Reaction.count' do + delete :destroy, params: { + id: reaction.id, + object_type: reaction.reactable_type, + object_id: reaction.reactable_id + }, xhr: true + end + + assert_response :success + end + + test 'create with invalid object type should respond with 403' do + # admin + @request.session[:user_id] = users(:users_001).id + + post :create, params: { + object_type: 'InvalidType', + object_id: 1 + }, xhr: true + + assert_response :forbidden + end + + test 'create without permission to view should respond with 403' do + # dlopper + @request.session[:user_id] = users(:users_003).id + + assert_no_difference 'Reaction.count' do + post :create, params: { + object_type: 'Issue', + # dlopper is not a member of the project where the issue (id=4) belongs. + object_id: issues(:issues_004).id + }, xhr: true + end + + assert_response :forbidden + end + + test 'destroy without permission to view should respond with 403' do + # dlopper + @request.session[:user_id] = users(:users_003).id + + # For Issue(id=6) + reaction = reactions(:reaction_005) + + assert_no_difference 'Reaction.count' do + delete :destroy, params: { + id: reaction.id, + object_type: reaction.reactable_type, + object_id: reaction.reactable_id + }, xhr: true + end + + assert_response :forbidden + end +end diff --git a/test/helpers/reactions_helper_test.rb b/test/helpers/reactions_helper_test.rb new file mode 100644 index 000000000..791fce9b7 --- /dev/null +++ b/test/helpers/reactions_helper_test.rb @@ -0,0 +1,146 @@ +# frozen_string_literal: true + +# Redmine - project management software +# Copyright (C) 2006- Jean-Philippe Lang +# +# 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. + +require_relative '../test_helper' + +class ReactionsHelperTest < ActionView::TestCase + include ReactionsHelper + + setup do + User.current = users(:users_002) + Setting.reactions_enabled = '1' + end + + test 'reaction_id_for generates a DOM id' do + assert_equal "reaction_issue_1", reaction_id_for(issues(:issues_001)) + end + + test 'reaction_button returns nil when feature is disabled' do + Setting.reactions_enabled = '0' + + assert_nil reaction_button(issues(:issues_004)) + end + + test 'reaction_button returns nil when object not visible' do + User.current = users(:users_003) + + assert_nil reaction_button(issues(:issues_004)) + end + + test 'reaction_button for anonymous users shows static icon' do + User.current = nil + + result = reaction_button(journals(:journals_001)) + + assert_select_in result, 'span.reaction-button[title=?]', 'John Smith' + assert_select_in result, 'a.reaction-button', false + end + + test 'reaction_button includes no tooltip when the object has no reactions' do + issue = issues(:issues_002) # Issue without reactions + result = reaction_button(issue) + + assert_select_in result, 'span.reaction-button[title]', false + end + + test 'reaction_button includes tooltip with all usernames when reactions are 10 or fewer' do + issue = issues(:issues_002) + + reactions = build_reactions(10) + issue.reactions += reactions + + result = with_locale 'en' do + reaction_button(issue) + end + + # The tooltip should display usernames in order of newest reactions. + expected_tooltip = 'Bob9 Doe, Bob8 Doe, Bob7 Doe, Bob6 Doe, Bob5 Doe, ' \ + 'Bob4 Doe, Bob3 Doe, Bob2 Doe, Bob1 Doe, and Bob0 Doe' + + assert_select_in result, 'a.reaction-button[title=?]', expected_tooltip + end + + test 'reaction_button includes tooltip with 10 usernames and others count when reactions exceed 10' do + issue = issues(:issues_002) + + reactions = build_reactions(11) + issue.reactions += reactions + + result = with_locale 'en' do + reaction_button(issue) + end + + expected_tooltip = 'Bob10 Doe, Bob9 Doe, Bob8 Doe, Bob7 Doe, Bob6 Doe, ' \ + 'Bob5 Doe, Bob4 Doe, Bob3 Doe, Bob2 Doe, Bob1 Doe, and 1 other' + + assert_select_in result, 'a.reaction-button[title=?]', expected_tooltip + end + + test 'reaction_button for reacted object' do + issue = issues(:issues_001) + reaction = issue.reactions.find_by(user: User.current) + + result = with_locale('en') do + reaction_button(issue, reaction) + end + tooltip = 'Dave Lopper, John Smith, and Redmine Admin' + + assert_select_in result, 'span[data-reaction-button-id=?]', 'reaction_issue_1' do + href = reaction_path(reaction, object_type: 'Issue', object_id: 1) + + assert_select 'a.icon.reaction-button.reacted[href=?]', href do + assert_select 'use[href*=?]', 'thumb-up-filled' + assert_select 'span.icon-label', '3' + end + + assert_select 'span.reaction-button', false + end + end + + test 'reaction_button for non-reacted object' do + User.current = users(:users_004) + + issue = issues(:issues_001) + reaction = issue.reactions.find_by(user: User.current) + + result = with_locale('en') do + reaction_button(issue, reaction) + end + tooltip = 'Dave Lopper, John Smith, and Redmine Admin' + + assert_select_in result, 'span[data-reaction-button-id=?]', 'reaction_issue_1' do + href = reactions_path(object_type: 'Issue', object_id: 1) + + assert_select 'a.icon.reaction-button[href=?]', href do + assert_select 'use[href*=?]', 'thumb-up' + assert_select 'span.icon-label', '3' + end + + assert_select 'span.reaction-button', false + end + end + + private + + def build_reactions(count) + Array.new(count) do |i| + Reaction.new(user: User.generate!(firstname: "Bob#{i}")) + end + end +end diff --git a/test/system/reactions_test.rb b/test/system/reactions_test.rb new file mode 100644 index 000000000..01ba76832 --- /dev/null +++ b/test/system/reactions_test.rb @@ -0,0 +1,132 @@ +# frozen_string_literal: true + +# Redmine - project management software +# Copyright (C) 2006- Jean-Philippe Lang +# +# 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. + +require_relative '../application_system_test_case' + +class ReactionsSystemTest < ApplicationSystemTestCase + def test_react_to_issue + log_user('jsmith', 'jsmith') + + issue = issues(:issues_002) + + with_settings(reactions_enabled: '1') do + visit '/issues/2' + reaction_button = find("div.issue.details [data-reaction-button-id=\"reaction_issue_#{issue.id}\"]") + assert_reaction_add_and_remove(reaction_button, issue) + end + end + + def test_react_to_journal + log_user('jsmith', 'jsmith') + + journal = journals(:journals_002) + + with_settings(reactions_enabled: '1') do + visit '/issues/1' + reaction_button = find("[data-reaction-button-id=\"reaction_journal_#{journal.id}\"]") + assert_reaction_add_and_remove(reaction_button, journal.reload) + end + end + + def test_react_to_forum_reply + log_user('jsmith', 'jsmith') + + reply_message = messages(:messages_002) # reply to message_001 + + with_settings(reactions_enabled: '1') do + visit 'boards/1/topics/1' + reaction_button = find("[data-reaction-button-id=\"reaction_message_#{reply_message.id}\"]") + assert_reaction_add_and_remove(reaction_button, reply_message) + end + end + + def test_react_to_forum_message + log_user('jsmith', 'jsmith') + + message = messages(:messages_001) + + with_settings(reactions_enabled: '1') do + visit 'boards/1/topics/1' + reaction_button = find("[data-reaction-button-id=\"reaction_message_#{message.id}\"]") + assert_reaction_add_and_remove(reaction_button, message) + end + end + + def test_react_to_news + log_user('jsmith', 'jsmith') + + with_settings(reactions_enabled: '1') do + visit '/news/2' + reaction_button = find("[data-reaction-button-id=\"reaction_news_2\"]") + assert_reaction_add_and_remove(reaction_button, news(:news_002)) + end + end + + def test_react_to_comment + log_user('jsmith', 'jsmith') + + comment = comments(:comments_002) + + with_settings(reactions_enabled: '1') do + visit '/news/1' + reaction_button = find("[data-reaction-button-id=\"reaction_comment_#{comment.id}\"]") + assert_reaction_add_and_remove(reaction_button, comment) + end + end + + def test_reactions_disabled + log_user('jsmith', 'jsmith') + + with_settings(reactions_enabled: '0') do + visit '/issues/1' + assert_no_selector('[data-reaction-button-id="reaction_issue_1"]') + end + end + + def test_reaction_button_is_visible_but_not_clickable_for_not_logged_in_user + with_settings(reactions_enabled: '1') do + visit '/issues/1' + + # visible + reaction_button = find('div.issue.details [data-reaction-button-id="reaction_issue_1"]') + within(reaction_button) { assert_selector('span.reaction-button') } + assert_equal "3", reaction_button.text + + # not clickable + within(reaction_button) { assert_no_selector('a.reaction-button') } + end + end + + private + + def assert_reaction_add_and_remove(reaction_button, expected_subject) + # Add a reaction + within(reaction_button) { find('a.reaction-button').click } + find('body').hover # Hide tooltip + within(reaction_button) { assert_selector('a.reaction-button.reacted[title="John Smith"]') } + assert_equal "1", reaction_button.text + assert_equal 1, expected_subject.reactions.count + + # Remove the reaction + within(reaction_button) { find('a.reacted').click } + within(reaction_button) { assert_selector('a.reaction-button:not(.reacted)') } + assert_equal "0", reaction_button.text + assert_equal 0, expected_subject.reactions.count + end +end diff --git a/test/unit/lib/redmine/reaction_test.rb b/test/unit/lib/redmine/reaction_test.rb new file mode 100644 index 000000000..a9c6d3502 --- /dev/null +++ b/test/unit/lib/redmine/reaction_test.rb @@ -0,0 +1,99 @@ +# frozen_string_literal: true + +# Redmine - project management software +# Copyright (C) 2006- Jean-Philippe Lang +# +# 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. + +require_relative '../../../test_helper' + +class Redmine::ReactionTest < ActiveSupport::TestCase + setup do + @user = users(:users_002) + @issue = issues(:issues_007) + Setting.reactions_enabled = '1' + end + + test 'reaction_by returns reaction for specific user' do + reaction = Reaction.create(reactable: @issue, user: @user) + + assert_equal reaction, @issue.reaction_by(@user) + assert_nil @issue.reaction_by(users(:users_003)) # Another user + end + + test 'reaction_by finds reaction when reactions are preloaded' do + reaction = Reaction.create(reactable: @issue, user: @user) + + issue_with_reactions = Issue.preload(:reactions).find(@issue.id) + + assert_no_queries do + assert_equal reaction.id, issue_with_reactions.reaction_by(@user).id + end + end + + test 'load_with_reactions returns an array and preloads reaction_user_names' do + issues = Issue.where(id: [1, 6]).order(:id).load_with_reactions + + assert_equal [issues(:issues_001), issues(:issues_006)], issues + + assert_no_queries do + assert_equal ['Dave Lopper', 'John Smith', 'Redmine Admin'], issues.first.reaction_user_names + assert_equal ['John Smith'], issues.second.reaction_user_names + end + end + + test 'load_with_reactions returns an array and does not preload reaction_user_names' do + Setting.reactions_enabled = '0' + + journals = Journal.where(id: 1).load_with_reactions + + assert_equal [journals(:journals_001)], journals + assert_nil journals.first.instance_variable_get(:@reaction_user_names) + end + + test 'reaction_user_names returns an array of user names' do + assert_equal ['John Smith'], comments(:comments_001).reaction_user_names + + # When user names are preloaded by load_with_reactions + comment = Comment.where(id: [1]).load_with_reactions.first + assert_no_queries do + assert_equal ['John Smith'], comment.reaction_user_names + end + end + + test 'reaction_users returns users ordered by their newest reactions' do + Reaction.create(reactable: @issue, user: users(:users_001)) + Reaction.create(reactable: @issue, user: users(:users_002)) + Reaction.create(reactable: @issue, user: users(:users_003)) + + assert_equal [ + users(:users_003), + users(:users_002), + users(:users_001) + ], @issue.reaction_users + end + + test 'destroy should delete associated reactions' do + @issue.reactions.create!( + [ + {user: users(:users_001)}, + {user: users(:users_002)} + ] + ) + assert_difference 'Reaction.count', -2 do + @issue.destroy + end + end +end diff --git a/test/unit/reaction_test.rb b/test/unit/reaction_test.rb new file mode 100644 index 000000000..c50958073 --- /dev/null +++ b/test/unit/reaction_test.rb @@ -0,0 +1,92 @@ +# frozen_string_literal: true + +# Redmine - project management software +# Copyright (C) 2006- Jean-Philippe Lang +# +# 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. + +require_relative '../test_helper' + +class ReactionTest < ActiveSupport::TestCase + test 'validates :inclusion of reactable_type' do + %w(Issue Journal News Comment Message).each do |type| + reaction = Reaction.new(reactable_type: type, user: User.new) + assert reaction.valid? + end + + assert_not Reaction.new(reactable_type: 'InvalidType', user: User.new).valid? + end + + test 'scope: by' do + user2_reactions = issues(:issues_001).reactions.by(users(:users_002)) + + assert_equal [reactions(:reaction_002)], user2_reactions + end + + test 'users_map_for_reactables returns correct mapping for given reactable_type and reactable_ids' do + issue1 = issues(:issues_001) + issue6 = issues(:issues_006) + + result = Reaction.users_map_for_reactables('Issue', [issue1.id, issue6.id]) + + expected_result = { + issue1.id => ['Dave Lopper', 'John Smith', 'Redmine Admin'], + issue6.id => ['John Smith'] + } + + assert_equal expected_result, result + end + + test 'users_map_for_reactables returns empty hash when no reactions exist' do + result = Reaction.users_map_for_reactables('Issue', [3, 4, 5]) + + assert_equal({}, result) + end + + test "should prevent duplicate reactions with unique constraint under concurrent creation" do + user = users(:users_001) + issue = issues(:issues_004) + + threads = [] + results = [] + + # Ensure both threads start at the same time + barrier = Concurrent::CyclicBarrier.new(2) + + # Create two threads to simulate concurrent creation + 2.times do + threads << Thread.new do + barrier.wait # Wait for both threads to be ready + begin + reaction = Reaction.create( + reactable: issue, + user: user + ) + results << reaction.persisted? + rescue ActiveRecord::RecordNotUnique + results << false + end + end + end + + # Wait for both threads to finish + threads.each(&:join) + + # Ensure only one reaction was created + assert_equal 1, Reaction.where(reactable: issue, user: user).count + assert_includes results, true + assert_equal 1, results.count(true) + end +end diff --git a/test/unit/user_test.rb b/test/unit/user_test.rb index ede12e1ce..aeae62df8 100644 --- a/test/unit/user_test.rb +++ b/test/unit/user_test.rb @@ -1376,4 +1376,16 @@ class UserTest < ActiveSupport::TestCase User.prune(7) end end + + def test_destroy_should_delete_associated_reactions + users(:users_004).reactions.create!( + [ + {reactable: issues(:issues_001)}, + {reactable: issues(:issues_002)} + ] + ) + assert_difference 'Reaction.count', -2 do + users(:users_004).destroy + end + end end