Add unique index to poll voters table
Note that Rails 7.1 changes `find_or_create_by!` so it calls `create_or_find_by!` when no record is found, meaning we'll rarely get `RecordNotUnique` exceptions when using this method during a race condition. Adding this index means we need to remove the uniqueness validation. According to the `create_or_find_by` documentation [1]: > Columns with unique database constraints should not have uniqueness > validations defined, otherwise create will fail due to validation > errors and find_by will never be called. We're adding a test that checks what happens when using `create_or_find_by!`. Note that we're creating voters combining `create_with` with `find_or_create_by!`. Using `find_or_create_by!(...)` with all attributes (including non-key ones like `origin`) fails when a voter already exists with different values, e.g. an existing `origin: "web"` and an incoming `"booth"`. In this situation the existing record is not matched and the unique index raises an exception. `create_with(...).find_or_create_by!(user: ..., poll: ...)` searches by the unique key only and applies the extra attributes only on creation. Existing voters are returned unchanged, which is the intended behavior. For the `take_votes_from` method, we're handling a (highly unlikely, but theoretically possible) scenario where a user votes at the same time as taking voters from another user. For that, we're doing something similar to what `create_or_find_by!` does: we're updating the `user_id` column inside a new transaction (using a new transactions avoids a `PG::InFailedSqlTransaction` exception when there are duplicate records), and deleting the existing voter when we get a `RecordNotUnique` exception. On `Poll::WebVote` we're simply raising an exception when there's already a user who's voted via booth, because the `Poll::WebVote#update` method should never be called in this case. We still need to use `with_lock` in `Poll::WebVote`, but not due to duplicate voters (`find_or_create_by!` method will now handle the unique record scenario, even in the case of simultaneous transactions), but because we use a uniqueness validation in `Poll::Answer`; this validation would cause an error in simultaneous transactions. [1] https://api.rubyonrails.org/v7.1/classes/ActiveRecord/Relation.html#method-i-create_or_find_by
This commit is contained in:
@@ -2,20 +2,19 @@ require "rails_helper"
|
||||
|
||||
describe Officing::VotersController do
|
||||
describe "POST create" do
|
||||
let(:officer) { create(:poll_officer) }
|
||||
before { sign_in(officer.user) }
|
||||
|
||||
it "does not create two records with two simultaneous requests", :race_condition do
|
||||
officer = create(:poll_officer)
|
||||
poll = create(:poll, officers: [officer])
|
||||
user = create(:user, :level_two)
|
||||
|
||||
sign_in(officer.user)
|
||||
|
||||
2.times.map do
|
||||
Thread.new do
|
||||
post :create, params: {
|
||||
voter: { poll_id: poll.id, user_id: user.id },
|
||||
format: :js
|
||||
}
|
||||
rescue ActiveRecord::RecordInvalid
|
||||
end
|
||||
end.each(&:join)
|
||||
|
||||
@@ -24,7 +23,6 @@ describe Officing::VotersController do
|
||||
end
|
||||
|
||||
it "stores officer and booth information" do
|
||||
officer = create(:poll_officer)
|
||||
user = create(:user, :in_census)
|
||||
poll1 = create(:poll, name: "Would you be interested in XYZ?")
|
||||
poll2 = create(:poll, name: "Testing polls")
|
||||
@@ -37,7 +35,6 @@ describe Officing::VotersController do
|
||||
|
||||
validate_officer
|
||||
set_officing_booth(booth)
|
||||
sign_in(officer.user)
|
||||
|
||||
post :create, params: {
|
||||
voter: { poll_id: poll1.id, user_id: user.id },
|
||||
@@ -61,5 +58,20 @@ describe Officing::VotersController do
|
||||
expect(voter2.booth_assignment).to eq(assignment2)
|
||||
expect(voter2.officer_assignment).to eq(assignment2.officer_assignments.first)
|
||||
end
|
||||
|
||||
it "does not overwrite non key attributes when a web voter already exists" do
|
||||
user = create(:user, :level_two, document_number: "11223344Z")
|
||||
poll = create(:poll, officers: [officer])
|
||||
existing = create(:poll_voter, poll: poll, user: user, origin: "web")
|
||||
|
||||
expect do
|
||||
post :create, params: { voter: { poll_id: poll.id, user_id: user.id }, format: :js }
|
||||
expect(response).to be_successful
|
||||
end.not_to change { Poll::Voter.count }
|
||||
|
||||
existing.reload
|
||||
expect(existing.origin).to eq "web"
|
||||
expect(existing.document_number).to eq "11223344Z"
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
@@ -4,49 +4,6 @@ describe "polls tasks" do
|
||||
let(:poll) { create(:poll) }
|
||||
let(:user) { create(:user, :level_two) }
|
||||
|
||||
describe "polls:remove_duplicate_voters" do
|
||||
before { Rake::Task["polls:remove_duplicate_voters"].reenable }
|
||||
|
||||
it "removes duplicate voters" do
|
||||
second_user = create(:user, :level_two)
|
||||
|
||||
voter = create(:poll_voter, poll: poll, user: user)
|
||||
second_voter = create(:poll_voter, poll: poll, user: second_user)
|
||||
other_user_voter = create(:poll_voter, poll: poll, user: create(:user, :level_two))
|
||||
other_poll_voter = create(:poll_voter, poll: create(:poll), user: user)
|
||||
|
||||
2.times { insert(:poll_voter, poll_id: poll.id, user_id: user.id) }
|
||||
insert(:poll_voter, poll_id: poll.id, user_id: second_user.id)
|
||||
|
||||
expect(Poll::Voter.count).to eq 7
|
||||
|
||||
Rake.application.invoke_task("polls:remove_duplicate_voters")
|
||||
|
||||
expect(Poll::Voter.count).to eq 4
|
||||
expect(Poll::Voter.all).to match_array [voter, second_voter, other_user_voter, other_poll_voter]
|
||||
end
|
||||
|
||||
it "removes duplicate voters on tenants" do
|
||||
create(:tenant, schema: "voters")
|
||||
|
||||
Tenant.switch("voters") do
|
||||
poll = create(:poll)
|
||||
user = create(:user, :level_two)
|
||||
|
||||
create(:poll_voter, poll: poll, user: user)
|
||||
insert(:poll_voter, poll_id: poll.id, user_id: user.id)
|
||||
|
||||
expect(Poll::Voter.count).to eq 2
|
||||
end
|
||||
|
||||
Rake.application.invoke_task("polls:remove_duplicate_voters")
|
||||
|
||||
Tenant.switch("voters") do
|
||||
expect(Poll::Voter.count).to eq 1
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
describe "polls:remove_duplicate_answers" do
|
||||
before { Rake::Task["polls:remove_duplicate_answers"].reenable }
|
||||
|
||||
|
||||
@@ -33,8 +33,7 @@ describe Poll::Voter do
|
||||
|
||||
voter = build(:poll_voter, user: user, poll: poll)
|
||||
|
||||
expect(voter).not_to be_valid
|
||||
expect(voter.errors.messages[:user_id]).to eq(["User has already voted"])
|
||||
expect { voter.save }.to raise_error ActiveRecord::RecordNotUnique
|
||||
end
|
||||
|
||||
it "is not valid if the user has already voted in the same poll/booth" do
|
||||
@@ -42,8 +41,7 @@ describe Poll::Voter do
|
||||
|
||||
voter = build(:poll_voter, user: user, poll: poll, booth_assignment: booth_assignment)
|
||||
|
||||
expect(voter).not_to be_valid
|
||||
expect(voter.errors.messages[:user_id]).to eq(["User has already voted"])
|
||||
expect { voter.save }.to raise_error ActiveRecord::RecordNotUnique
|
||||
end
|
||||
|
||||
it "is not valid if the user has already voted in different booth in the same poll" do
|
||||
@@ -51,8 +49,7 @@ describe Poll::Voter do
|
||||
|
||||
voter = build(:poll_voter, :from_booth, user: user, poll: poll, booth: booth)
|
||||
|
||||
expect(voter).not_to be_valid
|
||||
expect(voter.errors.messages[:user_id]).to eq(["User has already voted"])
|
||||
expect { voter.save }.to raise_error ActiveRecord::RecordNotUnique
|
||||
end
|
||||
|
||||
it "is valid if the user has already voted in the same booth in different poll" do
|
||||
@@ -68,8 +65,8 @@ describe Poll::Voter do
|
||||
create(:poll_voter, :from_web, user: answer.author, poll: answer.poll)
|
||||
|
||||
voter = build(:poll_voter, poll: answer.question.poll, user: answer.author)
|
||||
expect(voter).not_to be_valid
|
||||
expect(voter.errors.messages[:user_id]).to eq(["User has already voted"])
|
||||
|
||||
expect { voter.save }.to raise_error ActiveRecord::RecordNotUnique
|
||||
end
|
||||
|
||||
context "Skip verification is enabled" do
|
||||
@@ -83,7 +80,7 @@ describe Poll::Voter do
|
||||
|
||||
voter = build(:poll_voter, user: user, poll: poll)
|
||||
|
||||
expect(voter).not_to be_valid
|
||||
expect { voter.save }.to raise_error ActiveRecord::RecordNotUnique
|
||||
end
|
||||
|
||||
it "is valid if other users have voted in the same poll" do
|
||||
@@ -146,6 +143,15 @@ describe Poll::Voter do
|
||||
expect(voter).to be_valid
|
||||
end
|
||||
end
|
||||
|
||||
describe ".create_or_find_by!" do
|
||||
it "finds the voter when it already exists instead of failing the validation" do
|
||||
existing_voter = Poll::Voter.create_with(origin: "web")
|
||||
.create_or_find_by!(user: voter.user, poll: voter.poll)
|
||||
|
||||
expect(existing_voter).to eq voter
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
describe "scopes" do
|
||||
|
||||
@@ -73,6 +73,18 @@ describe Poll::WebVote do
|
||||
expect(question.reload.answers.size).to eq 0
|
||||
end
|
||||
|
||||
it "raises an exception when the user has already voted in a booth" do
|
||||
create(:poll_voter, :from_booth, poll: poll, user: user)
|
||||
|
||||
expect do
|
||||
web_vote.update(question.id.to_s => { option_id: option_yes.id.to_s })
|
||||
end.to raise_error(ActiveRecord::RecordNotFound)
|
||||
|
||||
expect(poll.reload.voters.size).to eq 1
|
||||
expect(poll.voters.first.user).to eq user
|
||||
expect(question.reload.answers.size).to eq 0
|
||||
end
|
||||
|
||||
context "creating answers at the same time", :race_condition do
|
||||
it "does not create two voters or two answers for two different answers" do
|
||||
[option_yes, option_no].map do |option|
|
||||
|
||||
Reference in New Issue
Block a user