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
124 lines
4.4 KiB
Ruby
124 lines
4.4 KiB
Ruby
require "rails_helper"
|
|
|
|
describe Poll::WebVote do
|
|
describe "#update" do
|
|
let(:user) { create(:user, :level_two) }
|
|
let(:poll) { create(:poll) }
|
|
let!(:question) { create(:poll_question, :yes_no, poll: poll) }
|
|
let(:option_yes) { question.question_options.find_by(title: "Yes") }
|
|
let(:option_no) { question.question_options.find_by(title: "No") }
|
|
let(:web_vote) { Poll::WebVote.new(poll, user) }
|
|
|
|
it "creates a poll_voter with user and poll data" do
|
|
expect(poll.voters).to be_blank
|
|
expect(question.answers).to be_blank
|
|
|
|
web_vote.update(question.id.to_s => { option_id: option_yes.id.to_s })
|
|
|
|
expect(poll.reload.voters.size).to eq 1
|
|
expect(question.reload.answers.size).to eq 1
|
|
|
|
voter = poll.voters.first
|
|
answer = question.answers.first
|
|
|
|
expect(answer.author).to eq user
|
|
expect(voter.document_number).to eq user.document_number
|
|
expect(voter.poll_id).to eq answer.poll.id
|
|
expect(voter.officer_id).to be nil
|
|
end
|
|
|
|
it "updates a poll_voter with user and poll data" do
|
|
answer = create(:poll_answer, question: question, author: user, option: option_yes)
|
|
|
|
web_vote.update(question.id.to_s => { option_id: option_no.id.to_s })
|
|
|
|
expect(poll.reload.voters.size).to eq 1
|
|
expect(question.reload.answers.size).to eq 1
|
|
expect(question.answers.first).to eq answer.reload
|
|
|
|
voter = poll.voters.first
|
|
|
|
expect(answer.author).to eq user
|
|
expect(answer.option).to eq option_no
|
|
expect(voter.document_number).to eq answer.author.document_number
|
|
expect(voter.poll_id).to eq answer.poll.id
|
|
end
|
|
|
|
it "does not save the answer if the voter is invalid" do
|
|
allow_any_instance_of(Poll::Voter).to receive(:valid?).and_return(false)
|
|
|
|
expect do
|
|
web_vote.update(question.id.to_s => { option_id: option_yes.id.to_s })
|
|
end.to raise_error(ActiveRecord::RecordInvalid)
|
|
|
|
expect(poll.voters).to be_blank
|
|
expect(question.answers).to be_blank
|
|
end
|
|
|
|
it "creates a voter but does not create answers when leaving everything blank" do
|
|
web_vote.update({})
|
|
|
|
expect(poll.reload.voters.size).to eq 1
|
|
expect(question.reload.answers.size).to eq 0
|
|
end
|
|
|
|
it "deletes existing answers but keeps voters when no answers are given" do
|
|
create(:poll_answer, question: question, author: user, option: option_yes)
|
|
create(:poll_voter, poll: poll, user: user)
|
|
|
|
web_vote.update({})
|
|
|
|
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
|
|
|
|
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|
|
|
Thread.new { web_vote.update(question.id.to_s => { option_id: option.id.to_s }) }
|
|
end.each(&:join)
|
|
|
|
expect(Poll::Voter.count).to be 1
|
|
expect(Poll::Answer.count).to be 1
|
|
end
|
|
|
|
it "does not create two voters for duplicate answers" do
|
|
2.times.map do
|
|
Thread.new { web_vote.update(question.id.to_s => { option_id: option_yes.id.to_s }) }
|
|
end.each(&:join)
|
|
|
|
expect(Poll::Voter.count).to be 1
|
|
end
|
|
|
|
it "validates max votes on multiple-answer questions" do
|
|
question = create(:poll_question_multiple, :abc, poll: poll, max_votes: 2)
|
|
option_a = question.question_options.find_by(title: "Answer A")
|
|
option_b = question.question_options.find_by(title: "Answer B")
|
|
option_c = question.question_options.find_by(title: "Answer C")
|
|
create(:poll_answer, question: question, author: user, option: option_a)
|
|
|
|
[option_b, option_c].map do |option|
|
|
Thread.new do
|
|
web_vote.update(question.id.to_s => { option_id: [option_a.id.to_s, option.id.to_s] })
|
|
end
|
|
end.each(&:join)
|
|
|
|
expect(Poll::Answer.count).to be 2
|
|
end
|
|
end
|
|
end
|
|
end
|