Files
grecia/spec/models/budget/stats_spec.rb
Javi Martín d2d517059d Fix race condition with ballot lines
With two concurrent requests, it's possible to create two ballot lines
when only one of them should be created.

The reason is the code validating the line is not thread safe:

```
if ballot.amount_available(investment.heading) < investment.price.to_i
  errors.add(:money, "insufficient funds")
end
```

If the second request executes this code after the first request has
executed it but before the first request has saved the record to the
database, both records will pass this validation and both will be saved
to the database.

So we need to introduce a lock. Now when the second request tries to
lock the ballot, it finds it's already locked by the first request, and
will wait for the transaction of the first request to finish before
checking whether there are sufficient funds.

Note we need to disable transactions during the test; otherwise the
second thread will wait for the first one to finish.

Also note that we need to update a couple of tests because records are
reloaded when they're locked.

In one case, reloading the ballot causes `ballot.user` to be `nil`,
since the user is hidden. So we hide the user after creating all its
associated records (which is the scenario that would take place in real
life).

In the other case, reloading the ballot causes `ballot.user` to reload
as well. So we need to reload the user object used in the test too so it
gets the updates done on `ballot.user`.

I haven't been able to reproduce this behavior in a system test. The
following test works with Rails 5.0, but it stopped working when we
moved to system tests in commit 9427f014. After that commit, for reasons
I haven't been able to debug (reintroducing truncation with
DatabaseClaner didn't seem to affect this test, and neither did
increasing the number of threads in Puma), the two AJAX requests
executed here are no longer simultaneous; the second request waits for
the first one to finish.

scenario "Race conditions with simultaneous requests", :js do
  allow_any_instance_of(Budget::Ballot::Line).to receive(:check_sufficient_funds) do |object|
    allow(object).to receive(:check_sufficient_funds).and_call_original
    object.check_sufficient_funds
    sleep 0.3
  end

  ["First", "Second"].each do |title|
    create(:budget_investment, :selected,
      heading: california,
      price:   california.price,
      title:   title
    )
  end

  login_as(user)
  visit budget_investments_path(budget, heading_id: california.id)

  within(".budget-investment", text: "First") { click_link "Vote" }
  within(".budget-investment", text: "Second") { click_link "Vote" }

  expect(page).to have_link "Remove vote"
  expect(Budget::Ballot::Line.count).to eq 1
end
2020-07-12 22:11:40 +02:00

278 lines
9.3 KiB
Ruby

require "rails_helper"
describe Budget::Stats do
let(:budget) { create(:budget, :finished) }
let(:stats) { Budget::Stats.new(budget) }
let(:investment) { create(:budget_investment, :selected, budget: budget) }
describe "#participants" do
let!(:author) { investment.author }
let!(:author_and_voter) { create(:user, :hidden, votables: [investment]) }
let!(:voter) { create(:user, votables: [investment]) }
let!(:voter_and_balloter) { create(:user, votables: [investment], ballot_lines: [investment]) }
let!(:balloter) { create(:user, ballot_lines: [investment]) }
let!(:poll_balloter) { create(:user, :level_two) }
let!(:non_participant) { create(:user, :level_two) }
before do
balloter.hide
create(:budget_investment, :selected, budget: budget, author: author_and_voter)
create(:poll_voter, :from_booth, user: poll_balloter, budget: budget)
create(:poll_voter, :from_booth, user: non_participant, budget: create(:budget))
end
it "returns unique participants, including authors and hidden users" do
expect(stats.participants).to match_array(
[author, author_and_voter, voter, voter_and_balloter, balloter, poll_balloter]
)
expect(stats.total_participants).to be 6
end
end
describe "#total_participants_support_phase" do
it "returns the number of total participants in the support phase" do
2.times { create(:vote, votable: investment) }
create(:budget_ballot_line, investment: investment)
expect(stats.total_participants_support_phase).to be 2
end
it "counts a user who is voter and balloter" do
create(:user, votables: [investment], ballot_lines: [investment])
expect(stats.total_participants_support_phase).to be 1
end
end
describe "#total_participants_vote_phase" do
it "returns the number of total participants in the votes phase" do
2.times { create(:budget_ballot_line, investment: investment) }
create(:vote, votable: investment)
expect(stats.total_participants_vote_phase).to be 2
end
it "counts a user who is voter and balloter" do
create(:user, votables: [investment], ballot_lines: [investment])
expect(stats.total_participants_vote_phase).to be 1
end
it "includes balloters and poll balloters" do
create(:budget_ballot_line, investment: investment)
create(:poll_voter, :from_booth, budget: budget)
expect(stats.total_participants_vote_phase).to be 2
end
it "counts once a user who is balloter and poll balloter" do
poller_and_balloter = create(:user, :level_two, ballot_lines: [investment])
create(:poll_voter, :from_booth, user: poller_and_balloter, budget: budget)
expect(stats.total_participants_vote_phase).to be 1
end
it "doesn't count nil user ids" do
create(:budget_ballot_line, investment: investment,
ballot: create(:budget_ballot, budget: budget.reload, user: nil, physical: true)
)
expect(stats.total_participants_vote_phase).to be 0
end
end
describe "#total_budget_investments" do
it "returns the number of total budget investments" do
2.times { create(:budget_investment, budget: budget) }
create(:budget_investment, budget: create(:budget))
expect(stats.total_budget_investments).to be 2
end
end
describe "#total_votes" do
it "returns the number of total votes" do
create(:budget_ballot_line, investment: investment)
create(:budget_ballot_line, investment: create(:budget_investment, :selected, budget: budget))
expect(stats.total_votes).to be 2
end
end
describe "#total_selected_investments" do
it "returns the number of total selected investments" do
3.times { create(:budget_investment, :selected, budget: budget) }
create(:budget_investment, :selected, budget: create(:budget))
create(:budget_investment, :unfeasible, budget: budget)
expect(stats.total_selected_investments).to be 3
end
end
describe "#total_unfeasible_investments" do
it "returns the number of total unfeasible investments" do
3.times { create(:budget_investment, :unfeasible, budget: budget) }
create(:budget_investment, :unfeasible, budget: create(:budget))
create(:budget_investment, :selected, budget: budget)
expect(stats.total_unfeasible_investments).to be 3
end
end
describe "Participants by gender" do
before do
3.times { create(:user, gender: "male") }
2.times { create(:user, gender: "female") }
create(:user, gender: nil)
allow(stats).to receive(:participants).and_return(User.all)
end
describe "#total_male_participants" do
it "returns the number of total male participants" do
expect(stats.total_male_participants).to be 3
end
end
describe "#total_female_participants" do
it "returns the number of total female participants" do
expect(stats.total_female_participants).to be 2
end
end
describe "#male_percentage" do
it "returns the percentage of male participants" do
expect(stats.male_percentage).to be 60.0
end
end
describe "#female_percentage" do
it "returns the percentage of female participants" do
expect(stats.female_percentage).to be 40.0
end
end
end
describe "#participants_by_age" do
before do
[21, 22, 23, 23, 34, 42, 43, 44, 50, 51].each do |age|
create(:user, date_of_birth: age.years.ago)
end
allow(stats).to receive(:participants).and_return(User.all)
end
it "returns the age groups hash" do
expect(stats.participants_by_age["16 - 19"][:count]).to be 0
expect(stats.participants_by_age["20 - 24"][:count]).to be 4
expect(stats.participants_by_age["25 - 29"][:count]).to be 0
expect(stats.participants_by_age["30 - 34"][:count]).to be 1
expect(stats.participants_by_age["35 - 39"][:count]).to be 0
expect(stats.participants_by_age["40 - 44"][:count]).to be 3
expect(stats.participants_by_age["45 - 49"][:count]).to be 0
expect(stats.participants_by_age["50 - 54"][:count]).to be 2
expect(stats.participants_by_age["55 - 59"][:count]).to be 0
expect(stats.participants_by_age["60 - 64"][:count]).to be 0
expect(stats.participants_by_age["65 - 69"][:count]).to be 0
expect(stats.participants_by_age["70 - 74"][:count]).to be 0
end
end
describe "#headings" do
before do
investment.heading.update_column(:population, 1234)
create(:budget_investment, heading: investment.heading)
2.times { create(:vote, votable: investment) }
create(:budget_ballot_line, investment: investment)
end
it "returns headings data" do
heading_stats = stats.headings[investment.heading.id]
expect(heading_stats[:total_investments_count]).to be 2
expect(heading_stats[:total_participants_support_phase]).to be 2
expect(heading_stats[:total_participants_vote_phase]).to be 1
expect(heading_stats[:total_participants_every_phase]).to be 3
expect(heading_stats[:percentage_participants_support_phase]).to be 100.0
expect(heading_stats[:percentage_district_population_support_phase]).to be 0.162
expect(heading_stats[:percentage_participants_vote_phase]).to be 100.0
expect(heading_stats[:percentage_district_population_vote_phase]).to be 0.081
expect(heading_stats[:percentage_participants_every_phase]).to be 100.0
expect(heading_stats[:percentage_district_population_every_phase]).to be 0.243
end
end
describe "#support_phase_finished?" do
context "support phase isn't finished" do
before { budget.phase = "selecting" }
it "is false" do
expect(stats.support_phase_finished?).to be false
end
end
context "support phase is finished" do
before { budget.phase = "valuating" }
it "is false" do
expect(stats.support_phase_finished?).to be true
end
end
end
describe "#vote_phase_finished" do
context "support phase isn't finished" do
before { budget.phase = "reviewing_ballots" }
it "is false" do
expect(stats.vote_phase_finished?).to be false
end
end
context "vote phase is finished" do
before { budget.phase = "finished" }
it "is false" do
expect(stats.vote_phase_finished?).to be true
end
end
end
describe "#all_phases" do
context "no phases are finished" do
before do
allow(stats).to receive(:support_phase_finished?).and_return(false)
allow(stats).to receive(:vote_phase_finished?).and_return(false)
end
it "returns an empty array" do
expect(stats.all_phases).to eq []
end
end
context "one phase is finished" do
before do
allow(stats).to receive(:support_phase_finished?).and_return(true)
allow(stats).to receive(:vote_phase_finished?).and_return(false)
end
it "returns the finished phase" do
expect(stats.all_phases).to eq ["support"]
end
end
context "all phases are finished" do
before do
allow(stats).to receive(:support_phase_finished?).and_return(true)
allow(stats).to receive(:vote_phase_finished?).and_return(true)
end
it "returns the finished phases and a total phase" do
expect(stats.all_phases).to eq ["support", "vote", "every"]
end
end
end
end