From 8ae057b06eb6a59f0e0bf169377d6690542628c1 Mon Sep 17 00:00:00 2001 From: Ben Halpern Date: Sat, 15 Jun 2019 17:33:30 -0400 Subject: [PATCH] Beta polls feature (admin use only for now) (#3176) * Initial poll features * Add basic poll functionality * Finish (maybe) beta poll functionality --- app/assets/stylesheets/ltags/LiquidTags.scss | 1 + app/assets/stylesheets/ltags/PollTag.scss | 85 ++++++++++++ app/controllers/poll_skips_controller.rb | 19 +++ app/controllers/poll_votes_controller.rb | 30 +++++ app/decorators/article_decorator.rb | 4 - app/labor/markdown_parser.rb | 12 ++ app/liquid_tags/poll_tag.rb | 121 ++++++++++++++++++ app/models/article.rb | 9 ++ app/models/poll.rb | 35 +++++ app/models/poll_option.rb | 17 +++ app/models/poll_skip.rb | 12 ++ app/models/poll_vote.rb | 30 +++++ app/models/user.rb | 2 + app/views/articles/show.html.erb | 1 + app/views/liquids/_poll.html.erb | 12 ++ config/routes.rb | 2 + db/migrate/20190525233909_create_polls.rb | 14 ++ .../20190525233918_create_poll_options.rb | 12 ++ .../20190525233934_create_poll_votes.rb | 13 ++ .../20190611195955_create_poll_skips.rb | 12 ++ db/schema.rb | 39 ++++++ spec/factories/poll_options.rb | 5 + spec/factories/poll_skips.rb | 10 ++ spec/factories/poll_votes.rb | 4 + spec/factories/polls.rb | 6 + spec/models/article_spec.rb | 13 ++ spec/models/poll_option_spec.rb | 15 +++ spec/models/poll_skip_spec.rb | 35 +++++ spec/models/poll_spec.rb | 17 +++ spec/models/poll_vote_spec.rb | 54 ++++++++ spec/requests/poll_skips_spec.rb | 32 +++++ spec/requests/poll_votes_spec.rb | 53 ++++++++ 32 files changed, 722 insertions(+), 4 deletions(-) create mode 100644 app/assets/stylesheets/ltags/PollTag.scss create mode 100644 app/controllers/poll_skips_controller.rb create mode 100644 app/controllers/poll_votes_controller.rb create mode 100644 app/liquid_tags/poll_tag.rb create mode 100644 app/models/poll.rb create mode 100644 app/models/poll_option.rb create mode 100644 app/models/poll_skip.rb create mode 100644 app/models/poll_vote.rb create mode 100644 app/views/liquids/_poll.html.erb create mode 100644 db/migrate/20190525233909_create_polls.rb create mode 100644 db/migrate/20190525233918_create_poll_options.rb create mode 100644 db/migrate/20190525233934_create_poll_votes.rb create mode 100644 db/migrate/20190611195955_create_poll_skips.rb create mode 100644 spec/factories/poll_options.rb create mode 100644 spec/factories/poll_skips.rb create mode 100644 spec/factories/poll_votes.rb create mode 100644 spec/factories/polls.rb create mode 100644 spec/models/poll_option_spec.rb create mode 100644 spec/models/poll_skip_spec.rb create mode 100644 spec/models/poll_spec.rb create mode 100644 spec/models/poll_vote_spec.rb create mode 100644 spec/requests/poll_skips_spec.rb create mode 100644 spec/requests/poll_votes_spec.rb diff --git a/app/assets/stylesheets/ltags/LiquidTags.scss b/app/assets/stylesheets/ltags/LiquidTags.scss index 82024f19a..89f6eda72 100644 --- a/app/assets/stylesheets/ltags/LiquidTags.scss +++ b/app/assets/stylesheets/ltags/LiquidTags.scss @@ -10,3 +10,4 @@ @import 'InstagramTag'; @import 'GistTag'; @import 'GithubReadmeTag'; +@import 'PollTag'; diff --git a/app/assets/stylesheets/ltags/PollTag.scss b/app/assets/stylesheets/ltags/PollTag.scss new file mode 100644 index 000000000..e37fabd88 --- /dev/null +++ b/app/assets/stylesheets/ltags/PollTag.scss @@ -0,0 +1,85 @@ +@import 'variables'; + +.ltag-poll { + width: 600px; + max-width: 96%; + margin: auto; + border-radius: 3px; + border:1px solid $light-medium-gray !important; + box-shadow: $shadow; + box-sizing: border-box; + padding: 2px 15px 18px; + font-family: $helvetica; + margin:1.6em auto !important; + h3 { + margin: 15px 0px; + } + .ltag-pollanswers { + list-style-type: none !important; + margin: 0 !important; /* To remove default bottom margin */ + padding: 0 !important; + padding-bottom: 8px !important; + font-size: 0.9em !important; + li { + padding-left: 8px; + border-radius: 3px; + width: 95%; + position: relative; + &:hover { + background: $light-gray; + } + &.ltag-polloption-justshowmetheresults:hover { + background: transparent; + } + &.already-voted { + background: transparent; + } + } + input { + vertical-align: top; + margin-top: 1.05em; + } + label { + width: calc(100% - 40px); + padding: 2px 6px; + cursor: pointer; + vertical-align: -3px; + display: inline-block; + line-height: 1.25em; + } + .ltag-voting-results-count { + font-size: 0.7em; + color: $medium-gray; + text-align: left; + } + button { + width: 100%; + background: transparent; + border: 0px; + font-size: 0.7em; + color: $medium-gray; + text-align: left; + padding-left: 20px; + } + .ltag-votepercent { + display: inline-block; + position: absolute; + top: 0; + left: 0; + bottom: 0; + z-index: 3; + border-radius: 3px; + &.ltag-optionvotedfor { + background: $green; + font-weight: bold; + } + &.ltag-optionnotvotedfor { + background: $light-medium-gray; + } + } + .ltag-votepercenttext { + position: relative; + z-index: 5; + } + } +} \ No newline at end of file diff --git a/app/controllers/poll_skips_controller.rb b/app/controllers/poll_skips_controller.rb new file mode 100644 index 000000000..1d6c8abcf --- /dev/null +++ b/app/controllers/poll_skips_controller.rb @@ -0,0 +1,19 @@ +class PollSkipsController < ApplicationController + before_action :authenticate_user!, only: %i[create] + + def create + @poll_skip = PollSkip.create(poll_id: poll_skips_params[:poll_id], user_id: current_user.id) + @poll = Poll.find(poll_skips_params[:poll_id]) + render json: { voting_data: @poll.voting_data, + poll_id: poll_skips_params[:poll_id].to_i, + user_vote_poll_option_id: nil, + voted: false } + end + + private + + def poll_skips_params + accessible = %i[poll_id] + params.require(:poll_skip).permit(accessible) + end +end diff --git a/app/controllers/poll_votes_controller.rb b/app/controllers/poll_votes_controller.rb new file mode 100644 index 000000000..80dce9fa4 --- /dev/null +++ b/app/controllers/poll_votes_controller.rb @@ -0,0 +1,30 @@ +class PollVotesController < ApplicationController + before_action :authenticate_user!, only: %i[create] + + def show + @poll = Poll.find(params[:id]) #Querying the poll instead of the poll vote + @poll_vote = @poll.poll_votes.where(user_id: current_user).first + @poll_skip = @poll.poll_skips.where(user_id: current_user).first if @poll_vote.blank? + render json: { voting_data: @poll.voting_data, + poll_id: @poll.id, + user_vote_poll_option_id: @poll_vote&.poll_option_id, + voted: (@poll_vote || @poll_skip).present? } + end + + def create + @poll_option = PollOption.find(poll_vote_params[:poll_option_id]) + @poll_vote = PollVote.create(poll_option_id: @poll_option&.id, user_id: current_user.id, poll_id: @poll_option.poll_id) + @poll = @poll_option.reload.poll + render json: { voting_data: @poll.voting_data, + poll_id: @poll.id, + user_vote_poll_option_id: poll_vote_params[:poll_option_id].to_i, + voted: true } + end + + private + + def poll_vote_params + accessible = %i[poll_option_id] + params.require(:poll_vote).permit(accessible) + end +end diff --git a/app/decorators/article_decorator.rb b/app/decorators/article_decorator.rb index 58defc84d..afced4191 100644 --- a/app/decorators/article_decorator.rb +++ b/app/decorators/article_decorator.rb @@ -25,10 +25,6 @@ class ArticleDecorator < ApplicationDecorator "https://#{ApplicationConfig['APP_DOMAIN']}#{path}" end - def liquid_tags_used - MarkdownParser.new(body_markdown.to_s + comments_blob.to_s).tags_used - end - def title_length_classification if article.title.size > 105 "longest" diff --git a/app/labor/markdown_parser.rb b/app/labor/markdown_parser.rb index 56420d43f..3a59d35fe 100644 --- a/app/labor/markdown_parser.rb +++ b/app/labor/markdown_parser.rb @@ -58,6 +58,18 @@ class MarkdownParser attributes: allowed_attributes end + def evaluate_inline_limited_markdown + return if @content.blank? + + renderer = Redcarpet::Render::HTMLRouge.new(hard_wrap: true, filter_html: false) + markdown = Redcarpet::Markdown.new(renderer, REDCARPET_CONFIG) + allowed_tags = %w[strong i u b em code] + allowed_attributes = %w[href strong em ref rel src title alt class] + ActionController::Base.helpers.sanitize markdown.render(@content).html_safe, + tags: allowed_tags, + attributes: allowed_attributes + end + def evaluate_listings_markdown return if @content.blank? diff --git a/app/liquid_tags/poll_tag.rb b/app/liquid_tags/poll_tag.rb new file mode 100644 index 000000000..8c6fb3399 --- /dev/null +++ b/app/liquid_tags/poll_tag.rb @@ -0,0 +1,121 @@ +class PollTag < LiquidTagBase + PARTIAL = "liquids/poll".freeze + + def initialize(_tag_name, id_code, _tokens) + @poll = Poll.find(id_code) + end + + def render(_context) + ActionController::Base.new.render_to_string( + partial: PARTIAL, + locals: { + poll: @poll + }, + ) + end + + def find_poll(id_code) + Poll.find(id_code.to_i(26)) + rescue ActiveRecord::RecordNotFound + raise StandardError, "Invalid poll ID" + end + + def self.script + <<~JAVASCRIPT + if (document.head.querySelector( + 'meta[name="user-signed-in"][content="true"]', + )) { + + function displayPollResults(json) { + var totalVotes = json.voting_data.votes_count; + json.voting_data.votes_distribution.forEach(function(point) { + var pollOptionItem = document.getElementById('poll_option_list_item_'+point[0]); + var optionText = document.getElementById('poll_option_label_'+point[0]).textContent; + if (json.user_vote_poll_option_id === point[0]) { + var votedClass = 'optionvotedfor' + } else { + var votedClass = 'optionnotvotedfor' + } + if (totalVotes === 0) { + var percent = 0; + } else { + var percent = (point[1]/totalVotes)*100; + } + var roundedPercent = Math.round( percent * 10 ) / 10 + var percentFromRight = (100-roundedPercent) + var html = '\ + '+optionText+' — '+roundedPercent+'%'; + pollOptionItem.innerHTML = html; + pollOptionItem.classList.add('already-voted') + document.getElementById('showmethemoney-'+json.poll_id).innerHTML = ''+totalVotes+' total votes'; + }) + } + + var polls = document.getElementsByClassName('ltag-poll'); + for (i = 0; i < polls.length; i += 1) { + var poll = polls[i] + var pollId = poll.dataset.pollId + window.fetch('/poll_votes/'+pollId) + .then(function(response){ + response.json().then( + function(json){ + if (json.voted) { + displayPollResults(json) + } else { + var els = document.getElementById('poll_'+json.poll_id).getElementsByClassName('ltag-polloption') + for (i = 0; i < els.length; i += 1) { + els[i].addEventListener('click', function(e) { + var tokenMeta = document.querySelector("meta[name='csrf-token']") + if (!tokenMeta) { + alert('Whoops. There was an error. Your vote was not counted. Try refreshing the page.') + return + } + var csrfToken = tokenMeta.getAttribute('content') + var optionId = e.target.dataset.optionId + window.fetch('/poll_votes', { + method: 'POST', + headers: { + 'X-CSRF-Token': csrfToken, + 'Content-Type': 'application/json', + }, + body: JSON.stringify({poll_vote: { poll_option_id: optionId } }), + credentials: 'same-origin', + }).then(function(response){ + response.json().then(function(j){displayPollResults(j)}) + }) + }); + } + document.getElementById('showmethemoney-'+json.poll_id).addEventListener('click', function(e) { + pollId = this.dataset.pollId + window.fetch('/poll_skips', { + method: 'POST', + headers: { + 'X-CSRF-Token': csrfToken, + 'Content-Type': 'application/json', + }, + body: JSON.stringify({poll_skip: {poll_id: pollId }}), + credentials: 'same-origin', + }).then(function(response){ + response.json().then(function(j){displayPollResults(j)}) + }) + }); + } + } + ) + }) + } + } else { + var els = document.getElementsByClassName('ltag-poll') + for (i = 0; i < els.length; i += 1) { + els[i].onclick = function(e) { + if (typeof showModal !== "undefined") { + showModal('poll'); + } + } + } + } + JAVASCRIPT + end +end + +Liquid::Template.register_tag("poll", PollTag) diff --git a/app/models/article.rb b/app/models/article.rb index 2a5cfb326..cdfd4a54d 100644 --- a/app/models/article.rb +++ b/app/models/article.rb @@ -42,6 +42,7 @@ class Article < ApplicationRecord validate :validate_tag validate :validate_video validate :validate_collection_permission + validate :validate_liquid_tag_permissions validates :video_state, inclusion: { in: %w[PROGRESSING COMPLETED] }, allow_nil: true validates :cached_tag_list, length: { maximum: 86 } validates :main_image, url: { allow_blank: true, schemes: %w[https http] } @@ -444,6 +445,10 @@ class Article < ApplicationRecord Rails.logger.error(e) end + def liquid_tags_used + MarkdownParser.new(body_markdown.to_s + comments_blob.to_s).tags_used + end + private def update_notifications @@ -519,6 +524,10 @@ class Article < ApplicationRecord errors.add(:collection_id, "must be one you have permission to post to") if collection && collection.user_id != user_id end + def validate_liquid_tag_permissions #Admin only beta tags etc. + errors.add(:body_markdown, "must only use permitted tags") if liquid_tags_used.include?(PollTag) && !(user.has_role?(:super_admin) || user.has_role?(:admin)) + end + def create_slug if slug.blank? && title.present? && !published self.slug = title_to_slug + "-temp-slug-#{rand(10_000_000)}" diff --git a/app/models/poll.rb b/app/models/poll.rb new file mode 100644 index 000000000..636f24bce --- /dev/null +++ b/app/models/poll.rb @@ -0,0 +1,35 @@ +class Poll < ApplicationRecord + + attr_accessor :poll_options_input_array + + serialize :voting_data + + belongs_to :article + has_many :poll_options + has_many :poll_skips + has_many :poll_votes + + validates :prompt_markdown, presence: true, + length: { maximum: 128 } + validates :poll_options_input_array, presence: true, + length: { minimum: 2, maximum: 15 } + + after_create :create_poll_options + before_save :evaluate_markdown + + def voting_data + { votes_count: poll_votes_count, votes_distribution: poll_options.pluck(:id, :poll_votes_count) } + end + + private + + def create_poll_options + poll_options_input_array.each do |input| + PollOption.create!(markdown: input, poll_id: id) + end + end + + def evaluate_markdown + self.prompt_html = MarkdownParser.new(prompt_markdown).evaluate_inline_limited_markdown + end +end diff --git a/app/models/poll_option.rb b/app/models/poll_option.rb new file mode 100644 index 000000000..96436d011 --- /dev/null +++ b/app/models/poll_option.rb @@ -0,0 +1,17 @@ +class PollOption < ApplicationRecord + belongs_to :poll + has_many :poll_votes + + validates :markdown, presence: true, + length: { maximum: 128 } + + before_save :evaluate_markdown + + counter_culture :poll + + private + + def evaluate_markdown + self.processed_html = MarkdownParser.new(markdown).evaluate_inline_limited_markdown + end +end diff --git a/app/models/poll_skip.rb b/app/models/poll_skip.rb new file mode 100644 index 000000000..2a5f79e9b --- /dev/null +++ b/app/models/poll_skip.rb @@ -0,0 +1,12 @@ +class PollSkip < ApplicationRecord + belongs_to :poll + belongs_to :user + + validate :one_vote_per_poll_per_user + + private + + def one_vote_per_poll_per_user + errors.add(:base, "cannot vote more than once in one poll") if (poll.poll_votes.where(user_id: user_id).any? || poll.poll_skips.where(user_id: user_id).any?) + end +end diff --git a/app/models/poll_vote.rb b/app/models/poll_vote.rb new file mode 100644 index 000000000..b9f3110d1 --- /dev/null +++ b/app/models/poll_vote.rb @@ -0,0 +1,30 @@ +class PollVote < ApplicationRecord + belongs_to :user + belongs_to :poll_option + belongs_to :poll + + counter_culture :poll_option + counter_culture :poll + + validates :poll_id, presence: true, presence: true, uniqueness: { scope: :user_id } # In the future we'll remove this constraint if/when we allow multi-answer polls + validates :poll_option_id, presence: true, uniqueness: { scope: :user_id } + validate :one_vote_per_poll_per_user + + after_save :touch_poll_votes_count + after_destroy :touch_poll_votes_count + + def poll + poll_option.poll + end + + private + + def one_vote_per_poll_per_user + errors.add(:base, "cannot vote more than once in one poll") if poll.poll_votes.where(user_id: user_id).any? || poll.poll_skips.where(user_id: user_id).any? + end + + def touch_poll_votes_count + poll.update_column(:poll_votes_count, poll.poll_votes.size) + poll_option.update_column(:poll_votes_count, poll_option.poll_votes.size) + end +end diff --git a/app/models/user.rb b/app/models/user.rb index f7a72201c..bb686ac7e 100644 --- a/app/models/user.rb +++ b/app/models/user.rb @@ -38,6 +38,8 @@ class User < ApplicationRecord has_many :page_views has_many :credits has_many :classified_listings + has_many :poll_votes + has_many :poll_skips has_many :mentor_relationships_as_mentee, class_name: "MentorRelationship", foreign_key: "mentee_id", inverse_of: :mentee has_many :mentor_relationships_as_mentor, diff --git a/app/views/articles/show.html.erb b/app/views/articles/show.html.erb index a73bdd525..d065ffab2 100644 --- a/app/views/articles/show.html.erb +++ b/app/views/articles/show.html.erb @@ -236,6 +236,7 @@ <%= PodcastTag.script.html_safe %> <%= GistTag.script.html_safe %> <%= RunkitTag.script.html_safe %> + <%= PollTag.script.html_safe %> <% end %> diff --git a/app/views/liquids/_poll.html.erb b/app/views/liquids/_poll.html.erb new file mode 100644 index 000000000..0a6c2d0d2 --- /dev/null +++ b/app/views/liquids/_poll.html.erb @@ -0,0 +1,12 @@ +
+

<%= poll.prompt_html.html_safe %>

+ +
\ No newline at end of file diff --git a/config/routes.rb b/config/routes.rb index d78550603..a258cc50d 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -143,6 +143,8 @@ Rails.application.routes.draw do resources :credits, only: %i[index new create] resources :buffer_updates, only: [:create] resources :reading_list_items, only: [:update] + resources :poll_votes, only: %i[show create] + resources :poll_skips, only: [:create] get "/chat_channel_memberships/find_by_chat_channel_id" => "chat_channel_memberships#find_by_chat_channel_id" get "/credits/purchase" => "credits#new" diff --git a/db/migrate/20190525233909_create_polls.rb b/db/migrate/20190525233909_create_polls.rb new file mode 100644 index 000000000..62596d227 --- /dev/null +++ b/db/migrate/20190525233909_create_polls.rb @@ -0,0 +1,14 @@ +class CreatePolls < ActiveRecord::Migration[5.2] + def change + create_table :polls do |t| + t.bigint :article_id + t.string :prompt_markdown + t.string :prompt_html + t.boolean :allow_multiple_selections, default: false + t.integer :poll_options_count, null: false, default: 0 + t.integer :poll_votes_count, null: false, default: 0 + t.integer :poll_skips_count, null: false, default: 0 + t.timestamps + end + end +end diff --git a/db/migrate/20190525233918_create_poll_options.rb b/db/migrate/20190525233918_create_poll_options.rb new file mode 100644 index 000000000..7df70560e --- /dev/null +++ b/db/migrate/20190525233918_create_poll_options.rb @@ -0,0 +1,12 @@ +class CreatePollOptions < ActiveRecord::Migration[5.2] + def change + create_table :poll_options do |t| + t.bigint :poll_id + t.string :markdown + t.string :processed_html + t.boolean :counts_in_tabulation + t.integer :poll_votes_count, null: false, default: 0 + t.timestamps + end + end +end diff --git a/db/migrate/20190525233934_create_poll_votes.rb b/db/migrate/20190525233934_create_poll_votes.rb new file mode 100644 index 000000000..822f2c5e8 --- /dev/null +++ b/db/migrate/20190525233934_create_poll_votes.rb @@ -0,0 +1,13 @@ +class CreatePollVotes < ActiveRecord::Migration[5.2] + def change + create_table :poll_votes do |t| + t.bigint :user_id, null: false + t.bigint :poll_id, null: false + t.bigint :poll_option_id, null: false + t.timestamps + end + add_index :poll_votes, %i[poll_option_id user_id], + unique: true, + name: "index_poll_votes_on_poll_option_and_user" + end +end diff --git a/db/migrate/20190611195955_create_poll_skips.rb b/db/migrate/20190611195955_create_poll_skips.rb new file mode 100644 index 000000000..507e74a45 --- /dev/null +++ b/db/migrate/20190611195955_create_poll_skips.rb @@ -0,0 +1,12 @@ +class CreatePollSkips < ActiveRecord::Migration[5.2] + def change + create_table :poll_skips do |t| + t.bigint :user_id + t.bigint :poll_id + t.timestamps + end + add_index :poll_skips, %i[poll_id user_id], + unique: true, + name: "index_poll_skips_on_poll_and_user" + end +end diff --git a/db/schema.rb b/db/schema.rb index 458a4b8b1..098dbead6 100644 --- a/db/schema.rb +++ b/db/schema.rb @@ -685,6 +685,45 @@ ActiveRecord::Schema.define(version: 2019_06_14_093041) do t.index ["slug"], name: "index_podcasts_on_slug", unique: true end + create_table "poll_options", force: :cascade do |t| + t.boolean "counts_in_tabulation" + t.datetime "created_at", null: false + t.string "markdown" + t.bigint "poll_id" + t.integer "poll_votes_count", default: 0, null: false + t.string "processed_html" + t.datetime "updated_at", null: false + end + + create_table "poll_skips", force: :cascade do |t| + t.datetime "created_at", null: false + t.bigint "poll_id" + t.datetime "updated_at", null: false + t.bigint "user_id" + t.index ["poll_id", "user_id"], name: "index_poll_skips_on_poll_and_user", unique: true + end + + create_table "poll_votes", force: :cascade do |t| + t.datetime "created_at", null: false + t.bigint "poll_id", null: false + t.bigint "poll_option_id", null: false + t.datetime "updated_at", null: false + t.bigint "user_id", null: false + t.index ["poll_option_id", "user_id"], name: "index_poll_votes_on_poll_option_and_user", unique: true + end + + create_table "polls", force: :cascade do |t| + t.boolean "allow_multiple_selections", default: false + t.bigint "article_id" + t.datetime "created_at", null: false + t.integer "poll_options_count", default: 0, null: false + t.integer "poll_skips_count", default: 0, null: false + t.integer "poll_votes_count", default: 0, null: false + t.string "prompt_html" + t.string "prompt_markdown" + t.datetime "updated_at", null: false + end + create_table "push_notification_subscriptions", force: :cascade do |t| t.string "auth_key" t.datetime "created_at", null: false diff --git a/spec/factories/poll_options.rb b/spec/factories/poll_options.rb new file mode 100644 index 000000000..fca3faa01 --- /dev/null +++ b/spec/factories/poll_options.rb @@ -0,0 +1,5 @@ +FactoryBot.define do + factory :poll_option do + markdown { Faker::Hipster.words(3) } + end +end diff --git a/spec/factories/poll_skips.rb b/spec/factories/poll_skips.rb new file mode 100644 index 000000000..0bfdb36fc --- /dev/null +++ b/spec/factories/poll_skips.rb @@ -0,0 +1,10 @@ +FactoryBot.define do + factory :poll_skip do + # prompt_markdown { Faker::Hipster.words(5) } + # factory :poll_with_options do + # after(:create) do |poll| + # create_list(:poll_option, poll: poll) + # end + # end + end +end diff --git a/spec/factories/poll_votes.rb b/spec/factories/poll_votes.rb new file mode 100644 index 000000000..b636322e3 --- /dev/null +++ b/spec/factories/poll_votes.rb @@ -0,0 +1,4 @@ +FactoryBot.define do + factory :poll_vote do + end +end diff --git a/spec/factories/polls.rb b/spec/factories/polls.rb new file mode 100644 index 000000000..e42bd3af0 --- /dev/null +++ b/spec/factories/polls.rb @@ -0,0 +1,6 @@ +FactoryBot.define do + factory :poll do + prompt_markdown { Faker::Hipster.words(5) } + poll_options_input_array { [rand(5).to_s, rand(5).to_s, rand(5).to_s, rand(5).to_s] } + end +end diff --git a/spec/models/article_spec.rb b/spec/models/article_spec.rb index cfb417a33..23baec07f 100644 --- a/spec/models/article_spec.rb +++ b/spec/models/article_spec.rb @@ -413,6 +413,19 @@ RSpec.describe Article, type: :model do article.main_image = "https://image.com/image.png" expect(article.valid?).to eq(true) end + + it "does not allow the use of admin-only liquid tags for non-admins" do + poll = create(:poll, article_id: article.id) + article.body_markdown = "hello hey hey hey {% poll #{poll.id} %}" + expect(article.valid?).to eq(false) + end + + it "allows admins" do + poll = create(:poll, article_id: article.id) + article.user.add_role(:admin) + article.body_markdown = "hello hey hey hey {% poll #{poll.id} %}" + expect(article.valid?).to eq(true) + end end it "updates main_image_background_hex_color" do diff --git a/spec/models/poll_option_spec.rb b/spec/models/poll_option_spec.rb new file mode 100644 index 000000000..eeca4714d --- /dev/null +++ b/spec/models/poll_option_spec.rb @@ -0,0 +1,15 @@ +require "rails_helper" + +RSpec.describe PollOption, type: :model do + let(:article) { create(:article, featured: true) } + let(:poll) { create(:poll, article_id: article.id) } + + it "allows up to 128 markdown characters" do + poll_option = PollOption.create(markdown: "0" * 30, poll_id: poll.id) + expect(poll_option).to be_valid + end + it "disallows over 128 markdown characters" do + poll_option = PollOption.create(markdown: "0" * 200, poll_id: poll.id) + expect(poll_option).not_to be_valid + end +end diff --git a/spec/models/poll_skip_spec.rb b/spec/models/poll_skip_spec.rb new file mode 100644 index 000000000..10608b660 --- /dev/null +++ b/spec/models/poll_skip_spec.rb @@ -0,0 +1,35 @@ +require 'rails_helper' + +RSpec.describe PollSkip, type: :model do + let(:article) { create(:article, featured: true) } + let(:user) { create(:user) } + let(:poll) { create(:poll, article_id: article.id) } + + it "is unique across poll and user" do + PollSkip.create(user_id: user.id, poll_id: poll.id) + PollSkip.create(user_id: user.id, poll_id: poll.id) + PollSkip.create(user_id: user.id, poll_id: poll.id) + expect(PollSkip.all.size).to eq(1) + second_poll = create(:poll, article_id: article.id) + PollSkip.create(user_id: user.id, poll_id: second_poll.id) + expect(PollSkip.all.size).to eq(2) + end + + it "is unique across user and poll votes for the poll" do + PollVote.create(user_id: user.id, poll_id: poll.id, poll_option_id: poll.poll_options.last.id) + PollSkip.create(user_id: user.id, poll_id: poll.id) + PollSkip.create(user_id: user.id, poll_id: poll.id) + expect(PollSkip.all.size).to eq(0) + second_poll = create(:poll, article_id: article.id) + PollSkip.create(user_id: user.id, poll_id: second_poll.id) + expect(PollSkip.all.size).to eq(1) + end + + it "is prevents a poll vote from being cast" do + PollSkip.create(user_id: user.id, poll_id: poll.id) + PollSkip.create(user_id: user.id, poll_id: poll.id) + PollVote.create(user_id: user.id, poll_id: poll.id, poll_option_id: poll.poll_options.last.id) + expect(PollSkip.all.size).to eq(1) + expect(PollVote.all.size).to eq(0) + end +end diff --git a/spec/models/poll_spec.rb b/spec/models/poll_spec.rb new file mode 100644 index 000000000..656d04d8c --- /dev/null +++ b/spec/models/poll_spec.rb @@ -0,0 +1,17 @@ +require "rails_helper" + +RSpec.describe Poll, type: :model do + let(:article) { create(:article, featured: true) } + let(:poll) { create(:poll, article_id: article.id) } + + it "limits length of prompt" do + long_string = "0" * 200 + poll.prompt_markdown = long_string + expect(poll).not_to be_valid + end + + it "creates options from input" do + poll = create(:poll, article_id: article.id, poll_options_input_array: %w[hello goodbye heyheyhey]) + expect(poll.poll_options.size).to eq(3) + end +end diff --git a/spec/models/poll_vote_spec.rb b/spec/models/poll_vote_spec.rb new file mode 100644 index 000000000..d4d796206 --- /dev/null +++ b/spec/models/poll_vote_spec.rb @@ -0,0 +1,54 @@ +require "rails_helper" + +RSpec.describe PollVote, type: :model do + let(:article) { create(:article, featured: true) } + let(:user) { create(:user) } + let(:poll) { create(:poll, article_id: article.id) } + + it "limits one vote per user per poll" do + create(:poll_vote, poll_option_id: poll.poll_options.last.id, user_id: user.id, poll_id: poll.id) + PollVote.create(poll_option_id: poll.poll_options.first.id, user_id: user.id, poll_id: poll.id) + PollVote.create(poll_option_id: poll.poll_options.last.id, user_id: user.id, poll_id: poll.id) + expect(user.poll_votes.size).to eq(1) + end + + it "allows one vote per user across multiple polls" do + second_poll = create(:poll, article_id: article.id) + create(:poll_vote, poll_option_id: poll.poll_options.last.id, user_id: user.id, poll_id: poll.id) + create(:poll_vote, poll_option_id: second_poll.poll_options.last.id, user_id: user.id, poll_id: second_poll.id) + expect(user.reload.poll_votes.size).to eq(2) + end + + it "allows multiple people to vote in one poll" do + second_user = create(:user) + create(:poll_vote, poll_option_id: poll.poll_options.last.id, user_id: user.id, poll_id: poll.id) + create(:poll_vote, poll_option_id: poll.poll_options.last.id, user_id: second_user.id, poll_id: poll.id) + expect(user.poll_votes.size).to eq(1) + expect(second_user.poll_votes.size).to eq(1) + end + + it "disallows a user to skip poll after voting" do + create(:poll_vote, poll_option_id: poll.poll_options.last.id, user_id: user.id, poll_id: poll.id) + PollSkip.create(poll_id: poll.id, user_id: user.id) + expect(user.poll_skips.size).to eq(0) + end + + it "disallows a vote after skipping" do + PollSkip.create(poll_id: poll.id, user_id: user.id) + PollVote.create(poll_option_id: poll.poll_options.last.id, user_id: user.id, poll_id: poll.id) + expect(user.poll_votes.size).to eq(0) + end + + it "updates poll voting count after create" do + create(:poll_vote, poll_option_id: poll.poll_options.last.id, user_id: user.id, poll_id: poll.id) + expect(poll.reload.poll_votes_count).to eq(1) + expect(poll.reload.poll_options.last.poll_votes_count).to eq(1) + end + + it "updates poll voting count after create with accuracy alongside skips" do + create(:poll_vote, poll_option_id: poll.poll_options.last.id, user_id: user.id, poll_id: poll.id) + PollSkip.create(poll_id: poll.id, user_id: user.id) + expect(poll.reload.poll_votes_count).to eq(1) + expect(poll.reload.poll_options.last.poll_votes_count).to eq(1) + end +end diff --git a/spec/requests/poll_skips_spec.rb b/spec/requests/poll_skips_spec.rb new file mode 100644 index 000000000..d9b7af728 --- /dev/null +++ b/spec/requests/poll_skips_spec.rb @@ -0,0 +1,32 @@ +require "rails_helper" + +RSpec.describe "PollSkips", type: :request do + let(:user) { create(:user) } + let(:article) { create(:article) } + let(:poll) { create(:poll, article_id: article.id) } + + before { sign_in user } + + describe "POST /poll_votes" do + it "votes on behalf of current user" do + post "/poll_skips", params: { + poll_skip: { poll_id: poll.id } + } + expect(JSON.parse(response.body)["voting_data"]["votes_count"]).to eq(0) + expect(JSON.parse(response.body)["voting_data"]["votes_distribution"]).to include([poll.poll_options.first.id, 0]) + expect(JSON.parse(response.body)["poll_id"]).to eq(poll.id) + expect(JSON.parse(response.body)["voted"]).to eq(false) + expect(user.poll_skips.size).to eq(1) + end + it "only allows one of vote or skip" do + post "/poll_skips", params: { + poll_skip: { poll_id: poll.id } + } + post "/poll_votes", params: { + poll_vote: { poll_option_id: poll.poll_options.first.id } + } + expect(user.poll_skips.size).to eq(1) + expect(user.poll_votes.size).to eq(0) + end + end +end diff --git a/spec/requests/poll_votes_spec.rb b/spec/requests/poll_votes_spec.rb new file mode 100644 index 000000000..e08ed0c40 --- /dev/null +++ b/spec/requests/poll_votes_spec.rb @@ -0,0 +1,53 @@ +require "rails_helper" + +RSpec.describe "PollVotes", type: :request do + let(:user) { create(:user) } + let(:article) { create(:article) } + let(:poll) { create(:poll, article_id: article.id) } + + before { sign_in user } + + describe "GET /poll_votes" do + it "returns proper results for poll" do + get "/poll_votes/#{poll.id}" + expect(JSON.parse(response.body)["voting_data"]["votes_count"]).to eq(0) + expect(JSON.parse(response.body)["voting_data"]["votes_distribution"]).to include([poll.poll_options.first.id, 0]) + expect(JSON.parse(response.body)["poll_id"]).to eq(poll.id) + expect(JSON.parse(response.body)["voted"]).to eq(false) + end + + it "returns proper results for poll if voted" do + create(:poll_vote, user_id: user.id, poll_option_id: poll.poll_options.first.id, poll_id: poll.id) + get "/poll_votes/#{poll.id}" + expect(JSON.parse(response.body)["voting_data"]["votes_count"]).to eq(1) + expect(JSON.parse(response.body)["voting_data"]["votes_distribution"]).to include([poll.poll_options.first.id, 1]) + expect(JSON.parse(response.body)["poll_id"]).to eq(poll.id) + expect(JSON.parse(response.body)["voted"]).to eq(true) + end + end + + describe "POST /poll_votes" do + it "votes on behalf of current user" do + post "/poll_votes", params: { + poll_vote: { poll_option_id: poll.poll_options.first.id } + } + expect(JSON.parse(response.body)["voting_data"]["votes_count"]).to eq(1) + expect(JSON.parse(response.body)["voting_data"]["votes_distribution"]).to include([poll.poll_options.first.id, 1]) + expect(JSON.parse(response.body)["poll_id"]).to eq(poll.id) + expect(JSON.parse(response.body)["voted"]).to eq(true) + expect(user.poll_votes.size).to eq(1) + end + it "votes on behalf of current user only once" do + post "/poll_votes", params: { + poll_vote: { poll_option_id: poll.poll_options.first.id } + } + post "/poll_votes", params: { + poll_vote: { poll_option_id: poll.poll_options.first.id } + } + expect(JSON.parse(response.body)["voting_data"]["votes_count"]).to eq(1) + expect(JSON.parse(response.body)["voting_data"]["votes_distribution"]).to include([poll.poll_options.first.id, 1]) + expect(JSON.parse(response.body)["voted"]).to eq(true) + expect(user.poll_votes.size).to eq(1) + end + end +end