From a849fae5ee86470600c161787e1e7f336b784ca9 Mon Sep 17 00:00:00 2001 From: Guo Xiang Tan Date: Mon, 6 Jun 2016 23:04:56 +0800 Subject: [PATCH] Add specs for polls (#4246) * Extract validation logic into a service object. * Extract logic for updating polls custom fields into a service object. * Use `strip_heredoc` instead. * FIX: Polls do not update when configuration has been changed. --- plugins/poll/lib/polls_updater.rb | 95 ++++++ plugins/poll/lib/polls_validator.rb | 112 +++++++ plugins/poll/plugin.rb | 172 ++--------- plugins/poll/spec/lib/polls_updater_spec.rb | 282 ++++++++++++++++++ plugins/poll/spec/lib/polls_validator_spec.rb | 225 ++++++++++++++ 5 files changed, 743 insertions(+), 143 deletions(-) create mode 100644 plugins/poll/lib/polls_updater.rb create mode 100644 plugins/poll/lib/polls_validator.rb create mode 100644 plugins/poll/spec/lib/polls_updater_spec.rb create mode 100644 plugins/poll/spec/lib/polls_validator_spec.rb diff --git a/plugins/poll/lib/polls_updater.rb b/plugins/poll/lib/polls_updater.rb new file mode 100644 index 00000000000..f1c6824e2a5 --- /dev/null +++ b/plugins/poll/lib/polls_updater.rb @@ -0,0 +1,95 @@ +module DiscoursePoll + class PollsUpdater + VALID_POLLS_CONFIGS = %w{type min max}.map(&:freeze) + + def self.update(post, polls) + # load previous polls + previous_polls = post.custom_fields[DiscoursePoll::POLLS_CUSTOM_FIELD] || {} + + # extract options + current_options = extract_option_ids(polls) + previous_options = extract_option_ids(previous_polls) + + # are the polls different? + if polls_updated?(polls, previous_polls) || (current_options != previous_options) + has_votes = total_votes(previous_polls) > 0 + + # outside of the 5-minute edit window? + if post.created_at < 5.minutes.ago && has_votes + # cannot add/remove/rename polls + if polls.keys.sort != previous_polls.keys.sort + post.errors.add(:base, I18n.t("poll.cannot_change_polls_after_5_minutes")) + return + end + + # deal with option changes + if User.staff.pluck(:id).include?(post.last_editor_id) + # staff can only edit options + polls.each_key do |poll_name| + if polls[poll_name]["options"].size != previous_polls[poll_name]["options"].size && previous_polls[poll_name]["voters"].to_i > 0 + post.errors.add(:base, I18n.t("poll.staff_cannot_add_or_remove_options_after_5_minutes")) + return + end + end + else + # OP cannot edit poll options + post.errors.add(:base, I18n.t("poll.op_cannot_edit_options_after_5_minutes")) + return + end + end + + # try to merge votes + polls.each_key do |poll_name| + next unless previous_polls.has_key?(poll_name) + + # when the # of options has changed, reset all the votes + if polls[poll_name]["options"].size != previous_polls[poll_name]["options"].size + PostCustomField.where(post_id: post.id, name: DiscoursePoll::VOTES_CUSTOM_FIELD).destroy_all + post.clear_custom_fields + next + end + + polls[poll_name]["voters"] = previous_polls[poll_name]["voters"] + polls[poll_name]["anonymous_voters"] = previous_polls[poll_name]["anonymous_voters"] if previous_polls[poll_name].has_key?("anonymous_voters") + + for o in 0...polls[poll_name]["options"].size + current_option = polls[poll_name]["options"][o] + previous_option = previous_polls[poll_name]["options"][o] + + current_option["votes"] = previous_option["votes"] + current_option["anonymous_votes"] = previous_option["anonymous_votes"] if previous_option.has_key?("anonymous_votes") + end + end + + # immediately store the polls + post.custom_fields[DiscoursePoll::POLLS_CUSTOM_FIELD] = polls + post.save_custom_fields(true) + + # publish the changes + MessageBus.publish("/polls/#{post.topic_id}", { post_id: post.id, polls: polls }) + end + end + + def self.polls_updated?(current_polls, previous_polls) + return true if (current_polls.keys.sort != previous_polls.keys.sort) + + current_polls.each_key do |poll_name| + if !previous_polls[poll_name] || + (current_polls[poll_name].values_at(*VALID_POLLS_CONFIGS) != previous_polls[poll_name].values_at(*VALID_POLLS_CONFIGS)) + + return true + end + end + + false + end + + def self.extract_option_ids(polls) + polls.values.map { |p| p["options"].map { |o| o["id"] } }.flatten.sort + end + + def self.total_votes(polls) + polls.map { |key, value| value["voters"].to_i }.sum + end + end +end diff --git a/plugins/poll/lib/polls_validator.rb b/plugins/poll/lib/polls_validator.rb new file mode 100644 index 00000000000..cb887616634 --- /dev/null +++ b/plugins/poll/lib/polls_validator.rb @@ -0,0 +1,112 @@ +module DiscoursePoll + class PollsValidator + def initialize(post) + @post = post + end + + def validate_polls + polls = {} + + extracted_polls = DiscoursePoll::Poll::extract(@post.raw, @post.topic_id) + + extracted_polls.each do |poll| + # polls should have a unique name + return false unless unique_poll_name?(polls, poll) + + # options must be unique + return false unless unique_options?(poll) + + # at least 2 options + return false unless at_least_two_options?(poll) + + # maximum # of options + return false unless valid_number_of_options?(poll) + + # poll with multiple choices + return false unless valid_multiple_choice_settings?(poll) + + # store the valid poll + polls[poll["name"]] = poll + end + + polls + end + + private + + def unique_poll_name?(polls, poll) + if polls.has_key?(poll["name"]) + if poll["name"] == ::DiscoursePoll::DEFAULT_POLL_NAME + @post.errors.add(:base, I18n.t("poll.multiple_polls_without_name")) + else + @post.errors.add(:base, I18n.t("poll.multiple_polls_with_same_name", name: poll["name"])) + end + + return false + end + + true + end + + def unique_options?(poll) + if poll["options"].map { |o| o["id"] }.uniq.size != poll["options"].size + if poll["name"] == ::DiscoursePoll::DEFAULT_POLL_NAME + @post.errors.add(:base, I18n.t("poll.default_poll_must_have_different_options")) + else + @post.errors.add(:base, I18n.t("poll.named_poll_must_have_different_options", name: poll["name"])) + end + + return false + end + + true + end + + def at_least_two_options?(poll) + if poll["options"].size < 2 + if poll["name"] == ::DiscoursePoll::DEFAULT_POLL_NAME + @post.errors.add(:base, I18n.t("poll.default_poll_must_have_at_least_2_options")) + else + @post.errors.add(:base, I18n.t("poll.named_poll_must_have_at_least_2_options", name: poll["name"])) + end + + return false + end + + true + end + + def valid_number_of_options?(poll) + if poll["options"].size > SiteSetting.poll_maximum_options + if poll["name"] == ::DiscoursePoll::DEFAULT_POLL_NAME + @post.errors.add(:base, I18n.t("poll.default_poll_must_have_less_options", count: SiteSetting.poll_maximum_options)) + else + @post.errors.add(:base, I18n.t("poll.named_poll_must_have_less_options", name: poll["name"], count: SiteSetting.poll_maximum_options)) + end + + return false + end + + true + end + + def valid_multiple_choice_settings?(poll) + if poll["type"] == "multiple" + min = (poll["min"].presence || 1).to_i + max = (poll["max"].presence || poll["options"].size).to_i + + if min > max || max <= 0 || max > poll["options"].size || min >= poll["options"].size + if poll["name"] == ::DiscoursePoll::DEFAULT_POLL_NAME + @post.errors.add(:base, I18n.t("poll.default_poll_with_multiple_choices_has_invalid_parameters")) + else + @post.errors.add(:base, I18n.t("poll.named_poll_with_multiple_choices_has_invalid_parameters", name: poll["name"])) + end + + return false + end + end + + true + end + end +end diff --git a/plugins/poll/plugin.rb b/plugins/poll/plugin.rb index 977be892fa6..251ec78b0cc 100644 --- a/plugins/poll/plugin.rb +++ b/plugins/poll/plugin.rb @@ -14,15 +14,18 @@ register_asset "javascripts/poll_dialect.js", :server_side PLUGIN_NAME ||= "discourse_poll".freeze -POLLS_CUSTOM_FIELD ||= "polls".freeze -VOTES_CUSTOM_FIELD ||= "polls-votes".freeze - DATA_PREFIX ||= "data-poll-".freeze -DEFAULT_POLL_NAME ||= "poll".freeze after_initialize do module ::DiscoursePoll + DEFAULT_POLL_NAME ||= "poll".freeze + POLLS_CUSTOM_FIELD ||= "polls".freeze + VOTES_CUSTOM_FIELD ||= "polls-votes".freeze + + autoload :PollsValidator, "#{Rails.root}/plugins/poll/lib/polls_validator" + autoload :PollsUpdater, "#{Rails.root}/plugins/poll/lib/polls_updater" + class Engine < ::Rails::Engine engine_name PLUGIN_NAME isolate_namespace DiscoursePoll @@ -46,7 +49,7 @@ after_initialize do raise StandardError.new I18n.t("poll.topic_must_be_open_to_vote") end - polls = post.custom_fields[POLLS_CUSTOM_FIELD] + polls = post.custom_fields[DiscoursePoll::POLLS_CUSTOM_FIELD] raise StandardError.new I18n.t("poll.no_polls_associated_with_this_post") if polls.blank? @@ -64,11 +67,11 @@ after_initialize do poll["voters"] = poll["anonymous_voters"] || 0 all_options = Hash.new(0) - post.custom_fields[VOTES_CUSTOM_FIELD] ||= {} - post.custom_fields[VOTES_CUSTOM_FIELD]["#{user_id}"] ||= {} - post.custom_fields[VOTES_CUSTOM_FIELD]["#{user_id}"][poll_name] = options + post.custom_fields[DiscoursePoll::VOTES_CUSTOM_FIELD] ||= {} + post.custom_fields[DiscoursePoll::VOTES_CUSTOM_FIELD]["#{user_id}"] ||= {} + post.custom_fields[DiscoursePoll::VOTES_CUSTOM_FIELD]["#{user_id}"][poll_name] = options - post.custom_fields[VOTES_CUSTOM_FIELD].each do |_, user_votes| + post.custom_fields[DiscoursePoll::VOTES_CUSTOM_FIELD].each do |_, user_votes| next unless votes = user_votes[poll_name] votes.each { |option| all_options[option] += 1 } poll["voters"] += 1 if (available_options & votes.to_set).size > 0 @@ -79,7 +82,7 @@ after_initialize do option["votes"] = all_options[option["id"]] + anonymous_votes end - post.custom_fields[POLLS_CUSTOM_FIELD] = polls + post.custom_fields[DiscoursePoll::POLLS_CUSTOM_FIELD] = polls post.save_custom_fields(true) MessageBus.publish("/polls/#{post.topic_id}", { post_id: post_id, polls: polls }) @@ -109,7 +112,7 @@ after_initialize do raise StandardError.new I18n.t("poll.only_staff_or_op_can_toggle_status") end - polls = post.custom_fields[POLLS_CUSTOM_FIELD] + polls = post.custom_fields[DiscoursePoll::POLLS_CUSTOM_FIELD] raise StandardError.new I18n.t("poll.no_polls_associated_with_this_post") if polls.blank? raise StandardError.new I18n.t("poll.no_poll_with_this_name", name: poll_name) if polls[poll_name].blank? @@ -214,7 +217,7 @@ after_initialize do polls = self.polls DistributedMutex.synchronize("#{PLUGIN_NAME}-#{post.id}") do - post.custom_fields[POLLS_CUSTOM_FIELD] = polls + post.custom_fields[DiscoursePoll::POLLS_CUSTOM_FIELD] = polls post.save_custom_fields(true) end end @@ -224,130 +227,13 @@ after_initialize do # only care when raw has changed! return unless self.raw_changed? - polls = {} - - extracted_polls = DiscoursePoll::Poll::extract(self.raw, self.topic_id) - - extracted_polls.each do |poll| - # polls should have a unique name - if polls.has_key?(poll["name"]) - poll["name"] == DEFAULT_POLL_NAME ? - self.errors.add(:base, I18n.t("poll.multiple_polls_without_name")) : - self.errors.add(:base, I18n.t("poll.multiple_polls_with_same_name", name: poll["name"])) - return - end - - # options must be unique - if poll["options"].map { |o| o["id"] }.uniq.size != poll["options"].size - poll["name"] == DEFAULT_POLL_NAME ? - self.errors.add(:base, I18n.t("poll.default_poll_must_have_different_options")) : - self.errors.add(:base, I18n.t("poll.named_poll_must_have_different_options", name: poll["name"])) - return - end - - # at least 2 options - if poll["options"].size < 2 - poll["name"] == DEFAULT_POLL_NAME ? - self.errors.add(:base, I18n.t("poll.default_poll_must_have_at_least_2_options")) : - self.errors.add(:base, I18n.t("poll.named_poll_must_have_at_least_2_options", name: poll["name"])) - return - end - - # maximum # of options - if poll["options"].size > SiteSetting.poll_maximum_options - poll["name"] == DEFAULT_POLL_NAME ? - self.errors.add(:base, I18n.t("poll.default_poll_must_have_less_options", count: SiteSetting.poll_maximum_options)) : - self.errors.add(:base, I18n.t("poll.named_poll_must_have_less_options", name: poll["name"], count: SiteSetting.poll_maximum_options)) - return - end - - # poll with multiple choices - if poll["type"] == "multiple" - min = (poll["min"].presence || 1).to_i - max = (poll["max"].presence || poll["options"].size).to_i - - if min > max || max <= 0 || max > poll["options"].size || min >= poll["options"].size - poll["name"] == DEFAULT_POLL_NAME ? - self.errors.add(:base, I18n.t("poll.default_poll_with_multiple_choices_has_invalid_parameters")) : - self.errors.add(:base, I18n.t("poll.named_poll_with_multiple_choices_has_invalid_parameters", name: poll["name"])) - return - end - end - - # store the valid poll - polls[poll["name"]] = poll - end + validator = DiscoursePoll::PollsValidator.new(self) + return unless (polls = validator.validate_polls) # are we updating a post? if self.id.present? - post = self - DistributedMutex.synchronize("#{PLUGIN_NAME}-#{post.id}") do - # load previous polls - previous_polls = post.custom_fields[POLLS_CUSTOM_FIELD] || {} - - # extract options - current_options = polls.values.map { |p| p["options"].map { |o| o["id"] } }.flatten.sort - previous_options = previous_polls.values.map { |p| p["options"].map { |o| o["id"] } }.flatten.sort - - # are the polls different? - if polls.keys != previous_polls.keys || current_options != previous_options - - has_votes = previous_polls.keys.map { |p| previous_polls[p]["voters"].to_i }.sum > 0 - - # outside of the 5-minute edit window? - if post.created_at < 5.minutes.ago && has_votes - # cannot add/remove/rename polls - if polls.keys.sort != previous_polls.keys.sort - post.errors.add(:base, I18n.t("poll.cannot_change_polls_after_5_minutes")) - return - end - - # deal with option changes - if User.staff.pluck(:id).include?(post.last_editor_id) - # staff can only edit options - polls.each_key do |poll_name| - if polls[poll_name]["options"].size != previous_polls[poll_name]["options"].size && previous_polls[poll_name]["voters"].to_i > 0 - post.errors.add(:base, I18n.t("poll.staff_cannot_add_or_remove_options_after_5_minutes")) - return - end - end - else - # OP cannot edit poll options - post.errors.add(:base, I18n.t("poll.op_cannot_edit_options_after_5_minutes")) - return - end - end - - # try to merge votes - polls.each_key do |poll_name| - next unless previous_polls.has_key?(poll_name) - - # when the # of options has changed, reset all the votes - if polls[poll_name]["options"].size != previous_polls[poll_name]["options"].size - PostCustomField.where(post_id: post.id, name: VOTES_CUSTOM_FIELD).destroy_all - post.clear_custom_fields - next - end - - polls[poll_name]["voters"] = previous_polls[poll_name]["voters"] - polls[poll_name]["anonymous_voters"] = previous_polls[poll_name]["anonymous_voters"] if previous_polls[poll_name].has_key?("anonymous_voters") - - for o in 0...polls[poll_name]["options"].size - current_option = polls[poll_name]["options"][o] - previous_option = previous_polls[poll_name]["options"][o] - - current_option["votes"] = previous_option["votes"] - current_option["anonymous_votes"] = previous_option["anonymous_votes"] if previous_option.has_key?("anonymous_votes") - end - end - - # immediately store the polls - post.custom_fields[POLLS_CUSTOM_FIELD] = polls - post.save_custom_fields(true) - - # publish the changes - MessageBus.publish("/polls/#{post.topic_id}", { post_id: post.id, polls: polls }) - end + DistributedMutex.synchronize("#{PLUGIN_NAME}-#{self.id}") do + DiscoursePoll::PollsUpdater.update(self, polls) end else self.polls = polls @@ -356,11 +242,11 @@ after_initialize do true end - Post.register_custom_field_type(POLLS_CUSTOM_FIELD, :json) - Post.register_custom_field_type(VOTES_CUSTOM_FIELD, :json) + Post.register_custom_field_type(DiscoursePoll::POLLS_CUSTOM_FIELD, :json) + Post.register_custom_field_type(DiscoursePoll::VOTES_CUSTOM_FIELD, :json) TopicView.add_post_custom_fields_whitelister do |user| - user ? [POLLS_CUSTOM_FIELD, VOTES_CUSTOM_FIELD] : [POLLS_CUSTOM_FIELD] + user ? [DiscoursePoll::POLLS_CUSTOM_FIELD, DiscoursePoll::VOTES_CUSTOM_FIELD] : [DiscoursePoll::POLLS_CUSTOM_FIELD] end on(:reduce_cooked) do |fragment, post| @@ -376,20 +262,20 @@ after_initialize do # tells the front-end we have a poll for that post on(:post_created) do |post| - next if post.is_first_post? || post.custom_fields[POLLS_CUSTOM_FIELD].blank? + next if post.is_first_post? || post.custom_fields[DiscoursePoll::POLLS_CUSTOM_FIELD].blank? MessageBus.publish("/polls/#{post.topic_id}", { post_id: post.id, - polls: post.custom_fields[POLLS_CUSTOM_FIELD]}) + polls: post.custom_fields[DiscoursePoll::POLLS_CUSTOM_FIELD]}) end - add_to_serializer(:post, :polls, false) { post_custom_fields[POLLS_CUSTOM_FIELD] } - add_to_serializer(:post, :include_polls?) { post_custom_fields.present? && post_custom_fields[POLLS_CUSTOM_FIELD].present? } + add_to_serializer(:post, :polls, false) { post_custom_fields[DiscoursePoll::POLLS_CUSTOM_FIELD] } + add_to_serializer(:post, :include_polls?) { post_custom_fields.present? && post_custom_fields[DiscoursePoll::POLLS_CUSTOM_FIELD].present? } - add_to_serializer(:post, :polls_votes, false) { post_custom_fields[VOTES_CUSTOM_FIELD]["#{scope.user.id}"] } + add_to_serializer(:post, :polls_votes, false) { post_custom_fields[DiscoursePoll::VOTES_CUSTOM_FIELD]["#{scope.user.id}"] } add_to_serializer(:post, :include_polls_votes?) do return unless scope.user return unless post_custom_fields.present? - return unless post_custom_fields[VOTES_CUSTOM_FIELD].present? - post_custom_fields[VOTES_CUSTOM_FIELD].has_key?("#{scope.user.id}") + return unless post_custom_fields[DiscoursePoll::VOTES_CUSTOM_FIELD].present? + post_custom_fields[DiscoursePoll::VOTES_CUSTOM_FIELD].has_key?("#{scope.user.id}") end end diff --git a/plugins/poll/spec/lib/polls_updater_spec.rb b/plugins/poll/spec/lib/polls_updater_spec.rb new file mode 100644 index 00000000000..125d5884581 --- /dev/null +++ b/plugins/poll/spec/lib/polls_updater_spec.rb @@ -0,0 +1,282 @@ +require 'rails_helper' + +describe DiscoursePoll::PollsUpdater do + let(:post_with_two_polls) do + raw = <<-RAW.strip_heredoc + [poll] + * 1 + * 2 + [/poll] + + [poll name=test] + * 1 + * 2 + [/poll] + RAW + + Fabricate(:post, raw: raw) + end + + let(:post) do + raw = <<-RAW.strip_heredoc + [poll] + * 1 + * 2 + [/poll] + RAW + + Fabricate(:post, raw: raw) + end + + let(:other_post) do + raw = <<-RAW.strip_heredoc + [poll] + * 3 + * 4 + * 5 + [/poll] + RAW + + Fabricate(:post, raw: raw) + end + + let(:polls) do + DiscoursePoll::PollsValidator.new(post).validate_polls + end + + let(:polls_with_3_options) do + DiscoursePoll::PollsValidator.new(other_post).validate_polls + end + + let(:two_polls) do + DiscoursePoll::PollsValidator.new(post_with_two_polls).validate_polls + end + + describe '.update' do + describe 'when post does not contain any polls' do + it 'should update polls correctly' do + post = Fabricate(:post) + + message = MessageBus.track_publish do + described_class.update(post, polls) + end.first + + expect(post.reload.custom_fields[DiscoursePoll::POLLS_CUSTOM_FIELD]).to eq(polls) + expect(message.data[:post_id]).to eq(post.id) + expect(message.data[:polls]).to eq(polls) + end + end + + describe 'when post contains existing polls' do + it "should be able to update polls correctly" do + message = MessageBus.track_publish do + described_class.update(post, polls_with_3_options) + end.first + + expect(post.reload.custom_fields[DiscoursePoll::POLLS_CUSTOM_FIELD]).to eq(polls_with_3_options) + expect(message.data[:post_id]).to eq(post.id) + expect(message.data[:polls]).to eq(polls_with_3_options) + end + end + + describe 'when there are no changes' do + it "should not do anything" do + messages = MessageBus.track_publish do + described_class.update(post, polls) + end + + expect(messages).to eq([]) + end + end + + context "polls of type 'multiple'" do + let(:min_2_post) do + raw = <<-RAW.strip_heredoc + [poll type=multiple min=2 max=3] + - Option 1 + - Option 2 + - Option 3 + [/poll] + RAW + + Fabricate(:post, raw: raw) + end + + let(:min_2_poll) do + DiscoursePoll::PollsValidator.new(min_2_post).validate_polls + end + + let(:min_1_post) do + raw = <<-RAW.strip_heredoc + [poll type=multiple min=1 max=2] + - Option 1 + - Option 2 + - Option 3 + [/poll] + RAW + + Fabricate(:post, raw: raw) + end + + let(:min_1_poll) do + DiscoursePoll::PollsValidator.new(min_1_post).validate_polls + end + + it "should be able to update options" do + min_2_poll + + message = MessageBus.track_publish do + described_class.update(min_2_post, min_1_poll) + end.first + + expect(min_2_post.reload.custom_fields[DiscoursePoll::POLLS_CUSTOM_FIELD]).to eq(min_1_poll) + expect(message.data[:post_id]).to eq(min_2_post.id) + expect(message.data[:polls]).to eq(min_1_poll) + end + end + + describe "when post has been created more than 5 minutes ago" do + let(:another_post) { Fabricate(:post, created_at: Time.zone.now - 5.minutes) } + + before do + polls.each { |key, value| value["voters"] = 2 } + described_class.update(another_post, polls) + end + + it "should not allow new polls to be added" do + messages = MessageBus.track_publish do + described_class.update(another_post, two_polls) + end + + expect(another_post.errors[:base]).to include(I18n.t( + "poll.cannot_change_polls_after_5_minutes") + ) + + expect(messages).to eq([]) + end + + it "should not allow users to edit options of current poll" do + messages = MessageBus.track_publish do + described_class.update(another_post, polls_with_3_options) + end + + expect(another_post.errors[:base]).to include(I18n.t( + "poll.op_cannot_edit_options_after_5_minutes" + )) + + expect(messages).to eq([]) + end + + context "staff" do + it "should not allow staff to add options if votes have been casted" do + another_post.update_attributes!(last_editor_id: User.staff.first.id) + + messages = MessageBus.track_publish do + described_class.update(another_post, polls_with_3_options) + end + + expect(another_post.errors[:base]).to include(I18n.t( + "poll.staff_cannot_add_or_remove_options_after_5_minutes" + )) + + expect(messages).to eq([]) + end + + it "should allow staff to add options if no votes have been casted" do + post.update_attributes!( + created_at: Time.zone.now - 5.minutes, + last_editor_id: User.staff.first.id + ) + + message = MessageBus.track_publish do + described_class.update(post, polls_with_3_options) + end.first + + expect(post.reload.custom_fields[DiscoursePoll::POLLS_CUSTOM_FIELD]).to eq(polls_with_3_options) + expect(message.data[:post_id]).to eq(post.id) + expect(message.data[:polls]).to eq(polls_with_3_options) + end + + it "should allow staff to edit options if votes have been casted" do + another_post.update_attributes!(last_editor_id: User.staff.first.id) + + raw = <<-RAW.strip_heredoc + [poll] + * 3 + * 4 + [/poll] + RAW + + different_post = Fabricate(:post, raw: raw) + different_polls = DiscoursePoll::PollsValidator.new(different_post).validate_polls + + message = MessageBus.track_publish do + described_class.update(another_post, different_polls) + end.first + + different_polls.each { |key, value| value["voters"] = 2 } + + expect(another_post.reload.custom_fields[DiscoursePoll::POLLS_CUSTOM_FIELD]).to eq(different_polls) + expect(message.data[:post_id]).to eq(another_post.id) + expect(message.data[:polls]).to eq(different_polls) + end + + it "should allow staff to edit options if votes have not been casted" do + post.update_attributes!(last_editor_id: User.staff.first.id) + + raw = <<-RAW.strip_heredoc + [poll] + * 3 + * 4 + [/poll] + RAW + + different_post = Fabricate(:post, raw: raw) + different_polls = DiscoursePoll::PollsValidator.new(different_post).validate_polls + + message = MessageBus.track_publish do + described_class.update(post, different_polls) + end.first + + expect(post.reload.custom_fields[DiscoursePoll::POLLS_CUSTOM_FIELD]).to eq(different_polls) + expect(message.data[:post_id]).to eq(post.id) + expect(message.data[:polls]).to eq(different_polls) + end + end + end + end + + describe '.extract_option_ids' do + it 'should return an array of the options id' do + expect(described_class.extract_option_ids(polls)).to eq( + ["4d8a15e3cc35750f016ce15a43937620", "cd314db7dfbac2b10687b6f39abfdf41"] + ) + end + end + + describe '.total_votes' do + let!(:post) do + raw = <<-RAW.strip_heredoc + [poll] + * 1 + * 2 + [/poll] + + [poll name=test] + * 1 + * 2 + [/poll] + RAW + + Fabricate(:post, raw: raw) + end + + it "should return the right number of votes" do + expect(described_class.total_votes(polls)).to eq(0) + + polls.each { |key, value| value["voters"] = 2 } + + expect(described_class.total_votes(polls)).to eq(4) + end + end +end diff --git a/plugins/poll/spec/lib/polls_validator_spec.rb b/plugins/poll/spec/lib/polls_validator_spec.rb new file mode 100644 index 00000000000..7278f3fdd68 --- /dev/null +++ b/plugins/poll/spec/lib/polls_validator_spec.rb @@ -0,0 +1,225 @@ +require 'rails_helper' + +describe ::DiscoursePoll::PollsValidator do + let(:post) { Fabricate(:post) } + subject { described_class.new(post) } + + describe "#validate_polls" do + it "should ensure that polls have unique names" do + raw = <<-RAW.strip_heredoc + [poll] + * 1 + * 2 + [/poll] + + [poll] + * 1 + * 2 + [/poll] + RAW + + expect(post.update_attributes(raw: raw)).to eq(false) + + expect(post.errors[:base]).to include( + I18n.t("poll.multiple_polls_without_name") + ) + + raw = <<-RAW.strip_heredoc + [poll name=test] + * 1 + * 2 + [/poll] + + [poll name=test] + * 1 + * 2 + [/poll] + RAW + + expect(post.update_attributes(raw: raw)).to eq(false) + + expect(post.errors[:base]).to include( + I18n.t("poll.multiple_polls_with_same_name", name: 'test') + ) + end + + it 'should ensure that polls have unique options' do + raw = <<-RAW.strip_heredoc + [poll] + * 1 + * 1 + [/poll] + RAW + + expect(post.update_attributes(raw: raw)).to eq(false) + + expect(post.errors[:base]).to include( + I18n.t("poll.default_poll_must_have_different_options") + ) + + raw = <<-RAW.strip_heredoc + [poll name=test] + * 1 + * 1 + [/poll] + RAW + + expect(post.update_attributes(raw: raw)).to eq(false) + + expect(post.errors[:base]).to include( + I18n.t("poll.named_poll_must_have_different_options", name: 'test') + ) + end + + + it 'should ensure that polls have at least 2 options' do + raw = <<-RAW.strip_heredoc + [poll] + * 1 + [/poll] + RAW + + expect(post.update_attributes(raw: raw)).to eq(false) + + expect(post.errors[:base]).to include( + I18n.t("poll.default_poll_must_have_at_least_2_options") + ) + + raw = <<-RAW.strip_heredoc + [poll name=test] + * 1 + [/poll] + RAW + + expect(post.update_attributes(raw: raw)).to eq(false) + + expect(post.errors[:base]).to include( + I18n.t("poll.named_poll_must_have_at_least_2_options", name: 'test') + ) + end + + it "should ensure that polls' options do not exceed site settings" do + SiteSetting.poll_maximum_options = 2 + + raw = <<-RAW.strip_heredoc + [poll] + * 1 + * 2 + * 3 + [/poll] + RAW + + expect(post.update_attributes(raw: raw)).to eq(false) + + expect(post.errors[:base]).to include(I18n.t( + "poll.default_poll_must_have_less_options", + count: SiteSetting.poll_maximum_options + )) + + raw = <<-RAW.strip_heredoc + [poll name=test] + * 1 + * 2 + * 3 + [/poll] + RAW + + expect(post.update_attributes(raw: raw)).to eq(false) + + expect(post.errors[:base]).to include(I18n.t( + "poll.named_poll_must_have_less_options", + name: 'test', count: SiteSetting.poll_maximum_options + )) + end + + describe 'multiple type polls' do + it "should ensure that min should not be greater than max" do + raw = <<-RAW.strip_heredoc + [poll type=multiple min=2 max=1] + * 1 + * 2 + * 3 + [/poll] + RAW + + expect(post.update_attributes(raw: raw)).to eq(false) + + expect(post.errors[:base]).to include( + I18n.t("poll.default_poll_with_multiple_choices_has_invalid_parameters") + ) + + raw = <<-RAW.strip_heredoc + [poll type=multiple min=2 max=1 name=test] + * 1 + * 2 + * 3 + [/poll] + RAW + + expect(post.update_attributes(raw: raw)).to eq(false) + + expect(post.errors[:base]).to include( + I18n.t("poll.named_poll_with_multiple_choices_has_invalid_parameters", name: 'test') + ) + end + + it "should ensure max setting is greater than 0" do + raw = <<-RAW.strip_heredoc + [poll type=multiple max=-2] + * 1 + * 2 + [/poll] + RAW + + expect(post.update_attributes(raw: raw)).to eq(false) + + expect(post.errors[:base]).to include( + I18n.t("poll.default_poll_with_multiple_choices_has_invalid_parameters") + ) + end + + it "should ensure that max settings is smaller or equal to the number of options" do + raw = <<-RAW.strip_heredoc + [poll type=multiple max=3] + * 1 + * 2 + [/poll] + RAW + + expect(post.update_attributes(raw: raw)).to eq(false) + + expect(post.errors[:base]).to include( + I18n.t("poll.default_poll_with_multiple_choices_has_invalid_parameters") + ) + end + + it "should ensure that min settings is smaller than the number of options" do + raw = <<-RAW.strip_heredoc + [poll type=multiple min=2] + * 1 + * 2 + [/poll] + RAW + + expect(post.update_attributes(raw: raw)).to eq(false) + + expect(post.errors[:base]).to include( + I18n.t("poll.default_poll_with_multiple_choices_has_invalid_parameters") + ) + + raw = <<-RAW.strip_heredoc + [poll type=multiple min=3] + * 1 + * 2 + [/poll] + RAW + + expect(post.update_attributes(raw: raw)).to eq(false) + + expect(post.errors[:base]).to include( + I18n.t("poll.default_poll_with_multiple_choices_has_invalid_parameters") + ) + end + end + end +end