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
221 lines
6.5 KiB
Ruby
221 lines
6.5 KiB
Ruby
require "rails_helper"
|
|
|
|
describe Poll::Voter do
|
|
describe "validations" do
|
|
let(:poll) { create(:poll) }
|
|
let(:booth) { create(:poll_booth) }
|
|
let(:booth_assignment) { create(:poll_booth_assignment, poll: poll, booth: booth) }
|
|
let(:voter) { create(:poll_voter) }
|
|
let(:user) { create(:user, :level_two) }
|
|
|
|
it "is valid" do
|
|
expect(voter).to be_valid
|
|
end
|
|
|
|
it "is not valid without a user" do
|
|
voter.user = nil
|
|
expect(voter).not_to be_valid
|
|
end
|
|
|
|
it "is not valid without a poll" do
|
|
voter.poll = nil
|
|
expect(voter).not_to be_valid
|
|
end
|
|
|
|
it "is valid if has not voted" do
|
|
voter = build(:poll_voter, :valid_document)
|
|
|
|
expect(voter).to be_valid
|
|
end
|
|
|
|
it "is not valid if the user has already voted in the same poll or booth_assignment" do
|
|
create(:poll_voter, user: user, poll: poll)
|
|
|
|
voter = build(:poll_voter, user: user, poll: poll)
|
|
|
|
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
|
|
create(:poll_voter, user: user, poll: poll, booth_assignment: booth_assignment)
|
|
|
|
voter = build(:poll_voter, user: user, poll: poll, booth_assignment: booth_assignment)
|
|
|
|
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
|
|
create(:poll_voter, :from_booth, user: user, poll: poll, booth: create(:poll_booth))
|
|
|
|
voter = build(:poll_voter, :from_booth, user: user, poll: poll, booth: booth)
|
|
|
|
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
|
|
create(:poll_voter, :from_booth, user: user, booth: booth, poll: create(:poll))
|
|
|
|
voter = build(:poll_voter, :from_booth, user: user, booth: booth, poll: poll)
|
|
|
|
expect(voter).to be_valid
|
|
end
|
|
|
|
it "is not valid if the user has voted via web" do
|
|
answer = create(:poll_answer)
|
|
create(:poll_voter, :from_web, user: answer.author, poll: answer.poll)
|
|
|
|
voter = build(:poll_voter, poll: answer.question.poll, user: answer.author)
|
|
|
|
expect { voter.save }.to raise_error ActiveRecord::RecordNotUnique
|
|
end
|
|
|
|
context "Skip verification is enabled" do
|
|
before do
|
|
Setting["feature.user.skip_verification"] = true
|
|
user.update!(document_number: nil, document_type: nil)
|
|
end
|
|
|
|
it "is not valid if the user has already voted in the same poll" do
|
|
create(:poll_voter, user: user, poll: poll)
|
|
|
|
voter = build(:poll_voter, user: user, poll: poll)
|
|
|
|
expect { voter.save }.to raise_error ActiveRecord::RecordNotUnique
|
|
end
|
|
|
|
it "is valid if other users have voted in the same poll" do
|
|
another_user = create(:user, :level_two, document_number: nil, document_type: nil)
|
|
create(:poll_voter, user: another_user, poll: poll)
|
|
|
|
voter = build(:poll_voter, user: user, poll: poll)
|
|
|
|
expect(voter).to be_valid
|
|
end
|
|
end
|
|
|
|
context "origin" do
|
|
it "is not valid without an origin" do
|
|
voter.origin = nil
|
|
expect(voter).not_to be_valid
|
|
end
|
|
|
|
it "is not valid without a valid origin" do
|
|
voter.origin = "invalid_origin"
|
|
expect(voter).not_to be_valid
|
|
end
|
|
|
|
it "is valid with a booth origin" do
|
|
voter.origin = "booth"
|
|
voter.officer_assignment = create(:poll_officer_assignment)
|
|
expect(voter).to be_valid
|
|
end
|
|
|
|
it "is valid with a web origin" do
|
|
voter.origin = "web"
|
|
expect(voter).to be_valid
|
|
end
|
|
|
|
it "dynamically validates the valid origins" do
|
|
stub_const("#{Poll::Voter}::VALID_ORIGINS", %w[custom])
|
|
|
|
expect(build(:poll_voter, origin: "custom")).to be_valid
|
|
expect(build(:poll_voter, origin: "web")).not_to be_valid
|
|
end
|
|
end
|
|
|
|
context "assignments" do
|
|
it "is not valid without a booth_assignment_id when origin is booth" do
|
|
voter.origin = "booth"
|
|
voter.booth_assignment_id = nil
|
|
expect(voter).not_to be_valid
|
|
end
|
|
|
|
it "is not valid without an officer_assignment_id when origin is booth" do
|
|
voter.origin = "booth"
|
|
voter.officer_assignment_id = nil
|
|
expect(voter).not_to be_valid
|
|
end
|
|
|
|
it "is valid without assignments when origin is web" do
|
|
voter.origin = "web"
|
|
voter.booth_assignment_id = nil
|
|
voter.officer_assignment_id = nil
|
|
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
|
|
describe "#web" do
|
|
it "returns voters with a web origin" do
|
|
voter = create(:poll_voter, :from_web)
|
|
|
|
expect(Poll::Voter.web).to eq [voter]
|
|
end
|
|
|
|
it "does not return voters with a booth origin" do
|
|
create(:poll_voter, :from_booth)
|
|
|
|
expect(Poll::Voter.web).to be_empty
|
|
end
|
|
end
|
|
|
|
describe "#booth" do
|
|
it "returns voters with a booth origin" do
|
|
voter = create(:poll_voter, :from_booth)
|
|
|
|
expect(Poll::Voter.booth).to eq [voter]
|
|
end
|
|
|
|
it "does not return voters with a web origin" do
|
|
create(:poll_voter, :from_web)
|
|
|
|
expect(Poll::Voter.booth).to be_empty
|
|
end
|
|
end
|
|
end
|
|
|
|
describe "save" do
|
|
it "sets demographic info" do
|
|
geozone = create(:geozone)
|
|
user = create(:user, :level_two,
|
|
geozone: geozone,
|
|
date_of_birth: 30.years.ago,
|
|
gender: "female")
|
|
|
|
voter = build(:poll_voter, user: user)
|
|
voter.save!
|
|
|
|
expect(voter.geozone).to eq(geozone)
|
|
expect(voter.age).to eq(30)
|
|
expect(voter.gender).to eq("female")
|
|
end
|
|
|
|
it "sets user info" do
|
|
user = create(:user, document_number: "1234A", document_type: "1")
|
|
voter = build(:poll_voter, user: user)
|
|
voter.save!
|
|
|
|
expect(voter.document_number).to eq("1234A")
|
|
expect(voter.document_type).to eq("1")
|
|
end
|
|
|
|
it "sets user info with skip verification enabled" do
|
|
Setting["feature.user.skip_verification"] = true
|
|
user = create(:user)
|
|
voter = build(:poll_voter, user: user)
|
|
|
|
expect { voter.save! }.not_to raise_exception
|
|
end
|
|
end
|
|
end
|