votes: allow admins to remove post votes.

Allow admins to remove votes on posts. This is for fixing vote abuse.

Votes can be removed by going to the vote list on the /post_votes page,
or by clicking on a post's score, then using the "Remove" option in the
"..." dropdown menu next to the vote.

Votes are soft-deleted - they're marked as deleted in the database, but
not fully deleted. Removed votes are only visible to admins, not to
regular users. When a vote is removed by an admin, it leaves a mod
action.

Technically it's possible to undelete votes, but there's no UI for it.
This commit is contained in:
evazion
2021-11-23 22:20:01 -06:00
parent 692f2848f2
commit 353e708538
19 changed files with 261 additions and 108 deletions

View File

@@ -2,7 +2,7 @@
<% if current_user.is_anonymous? %> <% if current_user.is_anonymous? %>
<%= link_to upvote_icon, login_path(url: request.fullpath), class: "post-upvote-link inactive-link" %> <%= link_to upvote_icon, login_path(url: request.fullpath), class: "post-upvote-link inactive-link" %>
<% elsif upvoted? %> <% elsif upvoted? %>
<%= link_to upvote_icon, post_post_votes_path(post_id: post.id), class: "post-upvote-link post-unvote-link active-link", method: :delete, remote: true %> <%= link_to upvote_icon, post_vote_path(current_vote), class: "post-upvote-link post-unvote-link active-link", method: :delete, remote: true %>
<% else %> <% else %>
<%= link_to upvote_icon, post_post_votes_path(post_id: post.id, score: 1), class: "post-upvote-link inactive-link", method: :post, remote: true %> <%= link_to upvote_icon, post_post_votes_path(post_id: post.id, score: 1), class: "post-upvote-link inactive-link", method: :post, remote: true %>
<% end %> <% end %>
@@ -14,7 +14,7 @@
<% if current_user.is_anonymous? %> <% if current_user.is_anonymous? %>
<%= link_to downvote_icon, login_path(url: request.fullpath), class: "post-downvote-link inactive-link" %> <%= link_to downvote_icon, login_path(url: request.fullpath), class: "post-downvote-link inactive-link" %>
<% elsif downvoted? %> <% elsif downvoted? %>
<%= link_to downvote_icon, post_post_votes_path(post_id: post.id), class: "post-downvote-link post-unvote-link active-link", method: :delete, remote: true %> <%= link_to downvote_icon, post_vote_path(current_vote), class: "post-downvote-link post-unvote-link active-link", method: :delete, remote: true %>
<% else %> <% else %>
<%= link_to downvote_icon, post_post_votes_path(post_id: post.id, score: -1), class: "post-downvote-link inactive-link", method: :post, remote: true %> <%= link_to downvote_icon, post_post_votes_path(post_id: post.id, score: -1), class: "post-downvote-link inactive-link", method: :post, remote: true %>
<% end %> <% end %>

View File

@@ -12,7 +12,7 @@ class PostVotesTooltipComponent < ApplicationComponent
end end
def votes def votes
@post.votes.includes(:user).order(id: :desc) @post.votes.active.includes(:user).order(id: :desc)
end end
def vote_icon(vote) def vote_icon(vote)

View File

@@ -2,7 +2,7 @@ class PostVotesController < ApplicationController
respond_to :js, :json, :xml, :html respond_to :js, :json, :xml, :html
def index def index
@post_votes = authorize PostVote.visible(CurrentUser.user).paginated_search(params, count_pages: true) @post_votes = authorize PostVote.visible(CurrentUser.user).paginated_search(params)
@post_votes = @post_votes.includes(:user, post: [:uploader, :media_asset]) if request.format.html? @post_votes = @post_votes.includes(:user, post: [:uploader, :media_asset]) if request.format.html?
@post = Post.find(params.dig(:search, :post_id)) if params.dig(:search, :post_id).present? @post = Post.find(params.dig(:search, :post_id)) if params.dig(:search, :post_id).present?
@@ -15,23 +15,28 @@ class PostVotesController < ApplicationController
end end
def create def create
@post = Post.find(params[:post_id]) @post_vote = authorize PostVote.new(post_id: params[:post_id], score: params[:score], user: CurrentUser.user)
@post_vote.save
@post.with_lock do @post = @post_vote.post.reload
@post_vote = authorize PostVote.new(post: @post, score: params[:score], user: CurrentUser.user)
PostVote.where(post: @post, user: CurrentUser.user).destroy_all
@post_vote.save
end
flash.now[:notice] = @post_vote.errors.full_messages.join("; ") if @post_vote.errors.present? flash.now[:notice] = @post_vote.errors.full_messages.join("; ") if @post_vote.errors.present?
respond_with(@post_vote) respond_with(@post_vote)
end end
def destroy def destroy
@post = Post.find(params[:post_id]) if params[:post_id].present?
@post_vote = @post.votes.find_by(user: CurrentUser.user) @post_vote = PostVote.active.find_by(post_id: params[:post_id], user_id: CurrentUser.user)
@post = Post.find(params[:post_id])
else
@post_vote = PostVote.find(params[:id])
@post = @post_vote.post
end
if @post_vote.present?
authorize(@post_vote).soft_delete(updater: CurrentUser.user)
@post.reload
end
authorize(@post_vote).destroy if @post_vote
respond_with(@post_vote) respond_with(@post_vote)
end end
end end

View File

@@ -215,9 +215,9 @@ class PostQueryBuilder
when "noteupdater" when "noteupdater"
user_subquery_matches(NoteVersion.unscoped, value, field: :updater) user_subquery_matches(NoteVersion.unscoped, value, field: :updater)
when "upvoter", "upvote" when "upvoter", "upvote"
user_subquery_matches(PostVote.positive.visible(current_user), value, field: :user) user_subquery_matches(PostVote.active.positive.visible(current_user), value, field: :user)
when "downvoter", "downvote" when "downvoter", "downvote"
user_subquery_matches(PostVote.negative.visible(current_user), value, field: :user) user_subquery_matches(PostVote.active.negative.visible(current_user), value, field: :user)
when *CATEGORY_COUNT_METATAGS when *CATEGORY_COUNT_METATAGS
short_category = name.delete_suffix("tags") short_category = name.delete_suffix("tags")
category = TagCategory.short_name_mapping[short_category] category = TagCategory.short_name_mapping[short_category]

View File

@@ -28,15 +28,13 @@ class Favorite < ApplicationRecord
end end
def upvote_post_on_create def upvote_post_on_create
if Pundit.policy!(user, PostVote).create? if Pundit.policy!(user, PostVote).create? && !PostVote.active.exists?(post: post, user: user, score: 1)
PostVote.negative.destroy_by(post: post, user: user) PostVote.create!(post: post, user: user, score: 1)
# Silently ignore the error if the user has already upvoted the post.
PostVote.create(post: post, user: user, score: 1)
end end
end end
def unvote_post_on_destroy def unvote_post_on_destroy
PostVote.positive.destroy_by(post: post, user: user) vote = PostVote.active.positive.find_by(post: post, user: user)
vote&.soft_delete!(updater: user)
end end
end end

View File

@@ -35,6 +35,8 @@ class ModAction < ApplicationRecord
post_note_lock_delete: 212, post_note_lock_delete: 212,
post_rating_lock_create: 220, post_rating_lock_create: 220,
post_rating_lock_delete: 222, post_rating_lock_delete: 222,
post_vote_delete: 232,
post_vote_undelete: 233,
pool_delete: 62, pool_delete: 62,
pool_undelete: 63, pool_undelete: 63,
artist_ban: 184, artist_ban: 184,

View File

@@ -40,7 +40,7 @@ class Post < ApplicationRecord
has_one :upload, :dependent => :destroy has_one :upload, :dependent => :destroy
has_one :artist_commentary, :dependent => :destroy has_one :artist_commentary, :dependent => :destroy
has_one :pixiv_ugoira_frame_data, class_name: "PixivUgoiraFrameData", foreign_key: :md5, primary_key: :md5 has_one :pixiv_ugoira_frame_data, class_name: "PixivUgoiraFrameData", foreign_key: :md5, primary_key: :md5
has_one :vote_by_current_user, -> { where(user_id: CurrentUser.id) }, class_name: "PostVote" # XXX using current user here is wrong has_one :vote_by_current_user, -> { active.where(user_id: CurrentUser.id) }, class_name: "PostVote" # XXX using current user here is wrong
has_many :flags, :class_name => "PostFlag", :dependent => :destroy has_many :flags, :class_name => "PostFlag", :dependent => :destroy
has_many :appeals, :class_name => "PostAppeal", :dependent => :destroy has_many :appeals, :class_name => "PostAppeal", :dependent => :destroy
has_many :votes, :class_name => "PostVote", :dependent => :destroy has_many :votes, :class_name => "PostVote", :dependent => :destroy
@@ -701,18 +701,10 @@ class Post < ApplicationRecord
return unless Pundit.policy!(voter, PostVote).create? return unless Pundit.policy!(voter, PostVote).create?
with_lock do with_lock do
votes.destroy_by(user: voter) votes.create!(user: voter, score: score) unless votes.active.exists?(user: voter, score: score)
votes.create!(user: voter, score: score)
reload # PostVote.create modifies our score. Reload to get the new score. reload # PostVote.create modifies our score. Reload to get the new score.
end end
end end
def unvote!(voter)
return unless Pundit.policy!(voter, PostVote).create?
votes.destroy_by(user: voter)
reload
end
end end
module ParentMethods module ParentMethods

View File

@@ -1,25 +1,36 @@
class PostVote < ApplicationRecord class PostVote < ApplicationRecord
attr_accessor :updater
belongs_to :post belongs_to :post
belongs_to :user belongs_to :user
validates :user_id, uniqueness: { scope: :post_id, message: "have already voted for this post" }
validates :score, inclusion: { in: [1, -1], message: "must be 1 or -1" } validates :score, inclusion: { in: [1, -1], message: "must be 1 or -1" }
after_create :update_score_after_create before_save { post.lock! }
after_destroy :update_score_after_destroy before_save :update_score_on_delete, if: -> { !new_record? && is_deleted_changed?(from: false, to: true) }
before_save :update_score_on_undelete, if: -> { !new_record? && is_deleted_changed?(from: true, to: false) }
before_save :create_mod_action_on_delete_or_undelete
before_create :update_score_on_create
before_create :remove_conflicting_votes
scope :positive, -> { where("post_votes.score > 0") } scope :positive, -> { where("post_votes.score > 0") }
scope :negative, -> { where("post_votes.score < 0") } scope :negative, -> { where("post_votes.score < 0") }
scope :public_votes, -> { positive.where(user: User.has_public_favorites) } scope :public_votes, -> { active.positive.where(user: User.has_public_favorites) }
deletable deletable
def self.visible(user) def self.visible(user)
user.is_admin? ? all : where(user: user).or(public_votes) if user.is_admin?
all
elsif user.is_anonymous?
public_votes
else
active.where(user: user).or(public_votes)
end
end end
def self.search(params) def self.search(params)
q = search_attributes(params, :id, :created_at, :updated_at, :score, :user, :post) q = search_attributes(params, :id, :created_at, :updated_at, :score, :is_deleted, :user, :post)
q.apply_default_order(params) q.apply_default_order(params)
end end
@@ -32,7 +43,19 @@ class PostVote < ApplicationRecord
score < 0 score < 0
end end
def update_score_after_create def remove_conflicting_votes
PostVote.active.where.not(id: id).where(post: post, user: user).each do |vote|
vote.soft_delete!(updater: updater)
end
end
def validate_vote_is_unique
if !is_deleted? && PostVote.active.where.not(id: id).exists?(post: post, user: user)
errors.add(:user, "have already voted for this post")
end
end
def update_score_on_create
if is_positive? if is_positive?
Post.update_counters(post_id, { score: score, up_score: score }) Post.update_counters(post_id, { score: score, up_score: score })
else else
@@ -40,7 +63,7 @@ class PostVote < ApplicationRecord
end end
end end
def update_score_after_destroy def update_score_on_delete
if is_positive? if is_positive?
Post.update_counters(post_id, { score: -score, up_score: -score }) Post.update_counters(post_id, { score: -score, up_score: -score })
else else
@@ -48,6 +71,20 @@ class PostVote < ApplicationRecord
end end
end end
def update_score_on_undelete
update_score_on_create
end
def create_mod_action_on_delete_or_undelete
return if new_record? || updater.nil? || updater == user
if is_deleted_changed?(from: false, to: true)
ModAction.log("#{updater.name} deleted post vote ##{id} on post ##{post_id}", :post_vote_delete, updater)
elsif is_deleted_changed?(from: true, to: false)
ModAction.log("#{updater.name} undeleted post vote ##{id} on post ##{post_id}", :post_vote_undelete, updater)
end
end
def self.available_includes def self.available_includes
[:user, :post] [:user, :post]
end end

View File

@@ -4,11 +4,11 @@ class PostVotePolicy < ApplicationPolicy
end end
def destroy? def destroy?
unbanned? && record.user == user record.user == user || user.is_admin?
end end
def show? def show?
user.is_admin? || record.user == user || (record.is_positive? && !record.user.enable_private_favorites?) user.is_admin? || record.user == user || (record.is_positive? && !record.is_deleted? && !record.user.enable_private_favorites?)
end end
def can_see_voter? def can_see_voter?

View File

@@ -1 +1 @@
$("span.post-votes[data-id=<%= @post.reload.id %>]").replaceWith("<%= j render_post_votes @post, current_user: CurrentUser.user %>"); $("span.post-votes[data-id=<%= @post.id %>]").replaceWith("<%= j render_post_votes @post, current_user: CurrentUser.user %>");

View File

@@ -0,0 +1,2 @@
Danbooru.Utility.notice("Vote removed");
location.reload();

View File

@@ -7,6 +7,10 @@
<%= table_for @post_votes, class: "striped autofit" do |t| %> <%= table_for @post_votes, class: "striped autofit" do |t| %>
<% t.column "Score" do |vote| %> <% t.column "Score" do |vote| %>
<%= link_to sprintf("%+d", vote.score), post_votes_path(search: { score: vote.score }) %> <%= link_to sprintf("%+d", vote.score), post_votes_path(search: { score: vote.score }) %>
<% if vote.is_deleted? %>
(deleted)
<% end %>
<% end %> <% end %>
<% t.column "Voter" do |vote| %> <% t.column "Voter" do |vote| %>
@@ -17,6 +21,16 @@
<% t.column "Created" do |vote| %> <% t.column "Created" do |vote| %>
<%= time_ago_in_words_tagged(vote.created_at) %> <%= time_ago_in_words_tagged(vote.created_at) %>
<% end %> <% end %>
<% t.column column: "control" do |vote| %>
<% if !vote.is_deleted? && policy(vote).destroy? %>
<%= render PopupMenuComponent.new do |menu| %>
<% menu.item do %>
<%= link_to "Remove", post_vote_path(vote.id, variant: "listing"), method: :delete, remote: true %>
<% end %>
<% end %>
<% end %>
<% end %>
<% end %> <% end %>
<%= numbered_paginator(@post_votes) %> <%= numbered_paginator(@post_votes) %>

View File

@@ -6,17 +6,25 @@
<% t.column "Post" do |vote| %> <% t.column "Post" do |vote| %>
<%= post_preview(vote.post, show_deleted: true) %> <%= post_preview(vote.post, show_deleted: true) %>
<% end %> <% end %>
<% t.column "Tags", td: {class: "col-expand"} do |vote| %> <% t.column "Tags", td: {class: "col-expand"} do |vote| %>
<%= render_inline_tag_list(vote.post) %> <%= render_inline_tag_list(vote.post) %>
<% end %> <% end %>
<% t.column "Score" do |vote| %> <% t.column "Score" do |vote| %>
<%= link_to sprintf("%+d", vote.score), post_votes_path(search: { score: vote.score }) %> <%= link_to sprintf("%+d", vote.score), post_votes_path(search: { score: vote.score }) %>
<% if vote.is_deleted? %>
(deleted)
<% end %>
<% end %> <% end %>
<% t.column "Uploader" do |vote| %> <% t.column "Uploader" do |vote| %>
<%= link_to_user vote.post.uploader %> <%= link_to_user vote.post.uploader %>
<%= link_to "»", post_votes_path(search: { post_tags_match: "user:#{vote.post.uploader.name}" }) %> <%= link_to "»", post_votes_path(search: { post_tags_match: "user:#{vote.post.uploader.name}" }) %>
<div><%= time_ago_in_words_tagged(vote.post.created_at) %></div> <div><%= time_ago_in_words_tagged(vote.post.created_at) %></div>
<% end %> <% end %>
<% t.column "Voter" do |vote| %> <% t.column "Voter" do |vote| %>
<% if policy(vote).can_see_voter? %> <% if policy(vote).can_see_voter? %>
<%= link_to_user vote.user %> <%= link_to_user vote.user %>
@@ -26,9 +34,14 @@
<% end %> <% end %>
<div><%= time_ago_in_words_tagged(vote.created_at) %></div> <div><%= time_ago_in_words_tagged(vote.created_at) %></div>
<% end %> <% end %>
<% t.column column: "control" do |vote| %> <% t.column column: "control" do |vote| %>
<% if vote.user == CurrentUser.user %> <% if !vote.is_deleted? && policy(vote).destroy? %>
<%= link_to "unvote", post_post_votes_path(vote.post), remote: true, method: :delete %> <%= render PopupMenuComponent.new do |menu| %>
<% menu.item do %>
<%= link_to "Remove", post_vote_path(vote.id, variant: "listing"), method: :delete, remote: true %>
<% end %>
<% end %>
<% end %> <% end %>
<% end %> <% end %>
<% end %> <% end %>

View File

@@ -189,7 +189,7 @@ Rails.application.routes.draw do
end end
resources :post_regenerations, :only => [:create] resources :post_regenerations, :only => [:create]
resources :post_replacements, :only => [:index, :new, :create, :update] resources :post_replacements, :only => [:index, :new, :create, :update]
resources :post_votes, only: [:index, :show] resources :post_votes, only: [:index, :show, :create, :destroy]
# XXX Use `only: []` to avoid redefining post routes defined at top of file. # XXX Use `only: []` to avoid redefining post routes defined at top of file.
resources :posts, only: [] do resources :posts, only: [] do

View File

@@ -86,10 +86,14 @@ class FavoritesControllerTest < ActionDispatch::IntegrationTest
context "destroy action" do context "destroy action" do
should "remove the favorite for the current user" do should "remove the favorite for the current user" do
assert_difference [-> { @faved_post.favorites.count }, -> { @faved_post.reload.fav_count }, -> { @user.reload.favorite_count }], -1 do delete_auth favorite_path(@faved_post.id), @user, as: :javascript
delete_auth favorite_path(@faved_post.id), @user, as: :javascript
assert_response :redirect assert_response :redirect
end assert_equal(0, @faved_post.favorites.count)
assert_equal(0, @faved_post.reload.fav_count)
assert_equal(0, @faved_post.votes.active.count)
assert_equal(1, @faved_post.votes.deleted.count)
assert_equal(0, @user.reload.favorite_count)
end end
should "allow banned users to destroy favorites" do should "allow banned users to destroy favorites" do

View File

@@ -175,51 +175,51 @@ class PostVotesControllerTest < ActionDispatch::IntegrationTest
context "create action" do context "create action" do
should "work for a JSON response" do should "work for a JSON response" do
post_auth post_post_votes_path(post_id: @post.id), @user, params: { score: 1, format: "json" } post_auth post_post_votes_path(post_id: @post.id, score: 1), @user, as: :json
assert_response 201 assert_response 201
assert_equal(1, @post.reload.score) assert_equal(1, @post.reload.score)
end end
should "not allow anonymous users to vote" do should "not allow anonymous users to vote" do
post post_post_votes_path(post_id: @post.id), params: { score: 1, format: "js" } post post_post_votes_path(post_id: @post.id, score: 1), xhr: true
assert_response 403 assert_response 403
assert_equal(0, @post.reload.score) assert_equal(0, @post.reload.score)
end end
should "not allow banned users to vote" do should "not allow banned users to vote" do
post_auth post_post_votes_path(post_id: @post.id), create(:banned_user), params: { score: 1, format: "js"} post_auth post_post_votes_path(post_id: @post.id, score: 1), create(:banned_user), xhr: true
assert_response 403 assert_response 403
assert_equal(0, @post.reload.score) assert_equal(0, @post.reload.score)
end end
should "not allow restricted users to vote" do should "not allow restricted users to vote" do
post_auth post_post_votes_path(post_id: @post.id), create(:restricted_user), params: { score: 1, format: "js"} post_auth post_post_votes_path(post_id: @post.id, score: 1), create(:restricted_user), xhr: true
assert_response 403 assert_response 403
assert_equal(0, @post.reload.score) assert_equal(0, @post.reload.score)
end end
should "allow members to vote" do should "allow members to vote" do
post_auth post_post_votes_path(post_id: @post.id), create(:user), params: { score: 1, format: "js" } post_auth post_post_votes_path(post_id: @post.id, score: 1), create(:user), xhr: true
assert_response :success assert_response :success
assert_equal(1, @post.reload.score) assert_equal(1, @post.reload.score)
end end
should "not allow invalid scores" do should "not allow invalid scores" do
post_auth post_post_votes_path(post_id: @post.id), @user, params: { score: 3, format: "js" } post_auth post_post_votes_path(post_id: @post.id, score: 3), @user, xhr: true
assert_response 200 assert_response :success
assert_equal(0, @post.reload.score) assert_equal(0, @post.reload.score)
assert_equal(0, @post.up_score) assert_equal(0, @post.up_score)
assert_equal(0, @post.votes.count) assert_equal(0, @post.votes.count)
end end
should "increment a post's score if the score is positive" do should "increment a post's score if the score is positive" do
post_auth post_post_votes_path(post_id: @post.id), @user, params: { score: 1, format: "js" } post_auth post_post_votes_path(post_id: @post.id, score: 1), @user, xhr: true
assert_response :success assert_response :success
assert_equal(1, @post.reload.score) assert_equal(1, @post.reload.score)
@@ -228,7 +228,7 @@ class PostVotesControllerTest < ActionDispatch::IntegrationTest
end end
should "decrement a post's score if the score is negative" do should "decrement a post's score if the score is negative" do
post_auth post_post_votes_path(post_id: @post.id), @user, params: { score: -1, format: "js" } post_auth post_post_votes_path(post_id: @post.id, score: -1), @user, xhr: true
assert_response :success assert_response :success
assert_equal(-1, @post.reload.score) assert_equal(-1, @post.reload.score)
@@ -238,45 +238,66 @@ class PostVotesControllerTest < ActionDispatch::IntegrationTest
context "for a post that has already been voted on" do context "for a post that has already been voted on" do
should "replace the vote" do should "replace the vote" do
@post.vote!(1, @user) vote = create(:post_vote, post: @post, user: @user, score: 1)
post_auth post_post_votes_path(post_id: @post.id, score: -1), @user, xhr: true
assert_no_difference("@post.votes.count") do assert_response :success
post_auth post_post_votes_path(post_id: @post.id), @user, params: { score: -1, format: "js" } assert_equal(-1, @post.reload.score)
assert_equal(0, @post.up_score)
assert_response :success assert_equal(-1, @post.down_score)
assert_equal(-1, @post.reload.score) assert_equal(1, @post.votes.negative.active.count)
assert_equal(0, @post.up_score) assert_equal(1, @post.votes.positive.deleted.count)
assert_equal(-1, @post.down_score) assert_equal(true, vote.reload.is_deleted?)
end
end end
end end
end end
context "destroy action" do context "destroy action" do
should "do nothing for anonymous users" do setup do
delete post_post_votes_path(post_id: @post.id), xhr: true @vote = create(:post_vote, post: @post, user: @user, score: 1)
assert_response 200
assert_equal(0, @post.reload.score)
end end
should "do nothing if the post hasn't been voted on" do should "allow users to remove their own votes" do
delete_auth post_post_votes_path(post_id: @post.id), @user, xhr: true delete_auth post_post_votes_path(post_id: @vote.post_id), @user, xhr: true
assert_response :success assert_response :success
assert_equal(0, @post.reload.score) assert_equal(0, @post.reload.score)
assert_equal(0, @post.down_score) assert_equal(0, @post.up_score)
assert_equal(0, @post.votes.count) assert_equal(0, @post.votes.active.count)
assert_equal(true, @vote.reload.is_deleted?)
end end
should "remove a vote" do should "not allow regular users to remove votes by other users" do
@post.vote!(1, @user) delete_auth post_vote_path(@vote), create(:user), xhr: true
delete_auth post_post_votes_path(post_id: @post.id), @user, xhr: true
assert_response 403
assert_equal(1, @post.reload.score)
assert_equal(1, @post.up_score)
assert_equal(1, @post.votes.active.count)
assert_equal(false, @vote.reload.is_deleted?)
end
should "allow admins to remove votes by other users" do
admin = create(:admin_user)
delete_auth post_vote_path(@vote), admin, xhr: true
assert_response :success assert_response :success
assert_equal(0, @post.reload.score) assert_equal(0, @post.reload.score)
assert_equal(0, @post.down_score) assert_equal(0, @post.up_score)
assert_equal(0, @post.votes.count) assert_equal(0, @post.votes.active.count)
assert_equal(true, @vote.reload.is_deleted?)
assert_match(/#{admin.name} deleted post vote #\d+ on post #\d+/, ModAction.post_vote_delete.last.description)
end
should "not fail when attempting to remove an already removed vote" do
@vote.soft_delete!
delete_auth post_post_votes_path(post_id: @vote.post_id), @user, xhr: true
assert_response :success
assert_equal(0, @post.reload.score)
assert_equal(0, @post.up_score)
assert_equal(0, @post.votes.active.count)
assert_equal(true, @vote.reload.is_deleted?)
end end
end end
end end

View File

@@ -40,7 +40,8 @@ class FavoriteTest < ActiveSupport::TestCase
assert_equal(0, @user.reload.favorite_count) assert_equal(0, @user.reload.favorite_count)
assert_equal(0, @p1.reload.fav_count) assert_equal(0, @p1.reload.fav_count)
assert_equal(0, @p1.reload.score) assert_equal(0, @p1.reload.score)
refute(PostVote.positive.exists?(post: @p1, user: @user)) refute(PostVote.active.positive.exists?(post: @p1, user: @user))
assert(PostVote.deleted.positive.exists?(post: @p1, user: @user))
end end
end end
@@ -75,8 +76,8 @@ class FavoriteTest < ActiveSupport::TestCase
assert_equal(1, @user.reload.favorite_count) assert_equal(1, @user.reload.favorite_count)
assert_equal(1, @p1.reload.fav_count) assert_equal(1, @p1.reload.fav_count)
assert_equal(1, @p1.reload.score) assert_equal(1, @p1.reload.score)
assert(PostVote.positive.exists?(post: @p1, user: @user)) assert(PostVote.active.positive.exists?(post: @p1, user: @user))
refute(PostVote.negative.exists?(post: @p1, user: @user)) assert(PostVote.deleted.negative.exists?(post: @p1, user: @user))
end end
should "not allow duplicate favorites" do should "not allow duplicate favorites" do

View File

@@ -701,12 +701,12 @@ class PostTest < ActiveSupport::TestCase
@post.update(tag_string: "aaa fav:self") @post.update(tag_string: "aaa fav:self")
assert_equal(1, @post.reload.score) assert_equal(1, @post.reload.score)
assert_equal(1, @post.favorites.where(user: @user).count) assert_equal(1, @post.favorites.where(user: @user).count)
assert_equal(1, @post.votes.positive.where(user: @user).count) assert_equal(1, @post.votes.active.positive.where(user: @user).count)
@post.update(tag_string: "aaa -fav:self") @post.update(tag_string: "aaa -fav:self")
assert_equal(0, @post.reload.score) assert_equal(0, @post.reload.score)
assert_equal(0, @post.favorites.count) assert_equal(0, @post.favorites.count)
assert_equal(0, @post.votes.positive.where(user: @user).count) assert_equal(0, @post.votes.active.positive.where(user: @user).count)
end end
should "not allow banned users to fav" do should "not allow banned users to fav" do
@@ -1433,11 +1433,12 @@ class PostTest < ActiveSupport::TestCase
end end
end end
should "not decrement the post's score for basic users" do should "decrement the post's score for basic users" do
@member = FactoryBot.create(:user) @member = FactoryBot.create(:user)
assert_no_difference("@post.score") { create(:favorite, post: @post, user: @member) } assert_difference("@post.reload.score", -1) do
assert_no_difference("@post.score") { Favorite.destroy_by(post: @post, user: @member) } Favorite.destroy_by(post: @post, user: @user)
end
end end
should "not decrement the user's favorite_count if the user did not favorite the post" do should "not decrement the user's favorite_count if the user did not favorite the post" do
@@ -1565,7 +1566,7 @@ class PostTest < ActiveSupport::TestCase
post.vote!(1, user) post.vote!(1, user)
assert_equal(1, post.reload.score) assert_equal(1, post.reload.score)
assert_equal(1, post.votes.count) assert_equal(1, post.votes.active.count)
end end
should "allow undoing of votes" do should "allow undoing of votes" do
@@ -1578,31 +1579,33 @@ class PostTest < ActiveSupport::TestCase
assert_equal(1, post.score) assert_equal(1, post.score)
assert_equal(1, post.up_score) assert_equal(1, post.up_score)
assert_equal(0, post.down_score) assert_equal(0, post.down_score)
assert_equal(1, post.votes.positive.count) assert_equal(1, post.votes.active.positive.count)
post.unvote!(user) post.votes.last.soft_delete!
post.reload
assert_equal(0, post.score) assert_equal(0, post.score)
assert_equal(0, post.up_score) assert_equal(0, post.up_score)
assert_equal(0, post.down_score) assert_equal(0, post.down_score)
assert_equal(0, post.votes.count) assert_equal(0, post.votes.active.count)
post.vote!(-1, user) post.vote!(-1, user)
assert_equal(-1, post.score) assert_equal(-1, post.score)
assert_equal(0, post.up_score) assert_equal(0, post.up_score)
assert_equal(-1, post.down_score) assert_equal(-1, post.down_score)
assert_equal(1, post.votes.negative.count) assert_equal(1, post.votes.active.negative.count)
post.unvote!(user) post.votes.last.soft_delete!
post.reload
assert_equal(0, post.score) assert_equal(0, post.score)
assert_equal(0, post.up_score) assert_equal(0, post.up_score)
assert_equal(0, post.down_score) assert_equal(0, post.down_score)
assert_equal(0, post.votes.count) assert_equal(0, post.votes.active.count)
post.vote!(1, user) post.vote!(1, user)
assert_equal(1, post.score) assert_equal(1, post.score)
assert_equal(1, post.up_score) assert_equal(1, post.up_score)
assert_equal(0, post.down_score) assert_equal(0, post.down_score)
assert_equal(1, post.votes.positive.count) assert_equal(1, post.votes.active.positive.count)
post.reload post.reload
assert_equal(1, post.score) assert_equal(1, post.score)

View File

@@ -9,7 +9,6 @@ class PostVoteTest < ActiveSupport::TestCase
context "during validation" do context "during validation" do
subject { build(:post_vote, post: @post) } subject { build(:post_vote, post: @post) }
should validate_uniqueness_of(:user_id).scoped_to(:post_id).with_message("have already voted for this post")
should validate_inclusion_of(:score).in_array([-1, 1]).with_message("must be 1 or -1") should validate_inclusion_of(:score).in_array([-1, 1]).with_message("must be 1 or -1")
end end
@@ -21,7 +20,20 @@ class PostVoteTest < ActiveSupport::TestCase
assert_equal(1, @post.reload.score) assert_equal(1, @post.reload.score)
assert_equal(1, @post.up_score) assert_equal(1, @post.up_score)
assert_equal(0, @post.down_score) assert_equal(0, @post.down_score)
assert_equal(1, @post.votes.positive.count) assert_equal(1, @post.votes.active.positive.count)
end
should "soft delete other votes" do
@user = create(:user)
vote1 = create(:post_vote, post: @post, user: @user, score: -1)
vote2 = create(:post_vote, post: @post, user: @user, score: 1)
assert_equal(1, @post.reload.score)
assert_equal(1, @post.up_score)
assert_equal(0, @post.down_score)
assert_equal(1, @post.votes.active.positive.count)
assert_equal(0, @post.votes.active.negative.count)
assert_equal(true, vote1.reload.is_deleted?)
end end
end end
@@ -32,25 +44,40 @@ class PostVoteTest < ActiveSupport::TestCase
assert_equal(-1, @post.reload.score) assert_equal(-1, @post.reload.score)
assert_equal(0, @post.up_score) assert_equal(0, @post.up_score)
assert_equal(-1, @post.down_score) assert_equal(-1, @post.down_score)
assert_equal(1, @post.votes.negative.count) assert_equal(1, @post.votes.active.negative.count)
end
should "soft delete other votes" do
@user = create(:user)
vote1 = create(:post_vote, post: @post, user: @user, score: 1)
vote2 = create(:post_vote, post: @post, user: @user, score: -1)
assert_equal(-1, @post.reload.score)
assert_equal(0, @post.up_score)
assert_equal(-1, @post.down_score)
assert_equal(0, @post.votes.active.positive.count)
assert_equal(1, @post.votes.active.negative.count)
assert_equal(true, vote1.reload.is_deleted?)
end end
end end
end end
context "destroying" do context "soft deleting" do
context "an upvote" do context "an upvote" do
should "decrement the post's score" do should "decrement the post's score" do
vote = create(:post_vote, post: @post, score: 1) vote = create(:post_vote, post: @post, score: 1)
assert_equal(1, @post.reload.score) assert_equal(1, @post.reload.score)
assert_equal(1, @post.up_score) assert_equal(1, @post.up_score)
assert_equal(0, @post.down_score) assert_equal(0, @post.down_score)
assert_equal(1, @post.votes.count) assert_equal(1, @post.votes.active.count)
assert_equal(0, @post.votes.deleted.count)
vote.destroy vote.soft_delete
assert_equal(0, @post.reload.score) assert_equal(0, @post.reload.score)
assert_equal(0, @post.up_score) assert_equal(0, @post.up_score)
assert_equal(0, @post.down_score) assert_equal(0, @post.down_score)
assert_equal(0, @post.votes.count) assert_equal(0, @post.votes.active.count)
assert_equal(1, @post.votes.deleted.count)
end end
end end
@@ -60,15 +87,49 @@ class PostVoteTest < ActiveSupport::TestCase
assert_equal(-1, @post.reload.score) assert_equal(-1, @post.reload.score)
assert_equal(0, @post.up_score) assert_equal(0, @post.up_score)
assert_equal(-1, @post.down_score) assert_equal(-1, @post.down_score)
assert_equal(1, @post.votes.count) assert_equal(1, @post.votes.active.count)
assert_equal(0, @post.votes.deleted.count)
vote.destroy vote.soft_delete
assert_equal(0, @post.reload.score) assert_equal(0, @post.reload.score)
assert_equal(0, @post.up_score) assert_equal(0, @post.up_score)
assert_equal(0, @post.down_score) assert_equal(0, @post.down_score)
assert_equal(0, @post.votes.count) assert_equal(0, @post.votes.active.count)
assert_equal(1, @post.votes.deleted.count)
end end
end end
end end
context "deleting a vote by another user" do
should "leave a mod action" do
admin = create(:admin_user, name: "admin")
vote = create(:post_vote, post: @post, score: 1)
vote.soft_delete!(updater: admin)
assert_match(/admin deleted post vote #\d+ on post #\d+/, ModAction.post_vote_delete.last.description)
end
end
context "undeleting a vote by another user" do
setup do
@admin = create(:admin_user, name: "admin")
@vote = create(:post_vote, post: @post, score: 1)
@vote.soft_delete!(updater: @admin)
@vote.update!(is_deleted: false, updater: @admin)
end
should "restore the score" do
assert_equal(1, @post.reload.score)
assert_equal(1, @post.up_score)
assert_equal(0, @post.down_score)
assert_equal(1, @post.votes.active.count)
assert_equal(0, @post.votes.deleted.count)
end
should "leave a mod action" do
assert_match(/admin undeleted post vote #\d+ on post #\d+/, ModAction.post_vote_undelete.last.description)
end
end
end end
end end