Skip to content

Commit 46a7184

Browse files
nbudinclaude
andcommitted
Fix rerun of moderated ranked choice rounds requesting the wrong choices
Rerunning a moderated ranked choice signup round advanced every attendee with a still-pending signup request one notch down their ranked choice list, instead of re-requesting the same choice. Two compounding bugs caused this, both rooted in priority being a list position scoped by (user_con_profile, state): - ExecuteRankedChoiceSignupService computed after_signup_ranked_choice after AcceptSignupRankedChoiceService had already moved the choice into the "requested" list, which renumbers the remaining pending choices. The unscoped priority comparison then recorded the wrong successor (e.g. the attendee's 3rd choice instead of their 2nd). It's now captured before the state change and scoped to pending choices. - RerunModeratedRankedChoiceSignupRoundService#undo_decision restored the undone choice after its recorded successor instead of before it. It now inserts before the successor, falling back to :first when the successor was itself executed and :last when there was no successor. Decisions are undone in execution order so multi-signup rounds restore correctly, and decisions whose ranked choice has since been deleted no longer crash the rerun. Adds test coverage for RerunModeratedRankedChoiceSignupRoundService, which previously had none, plus .rubocop_todo.yml entries for pre-existing offenses in the touched files that started firing with rubocop 1.88. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
1 parent 38477d8 commit 46a7184

4 files changed

Lines changed: 207 additions & 10 deletions

.rubocop_todo.yml

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -37,6 +37,12 @@ Lint/MissingCopEnableDirective:
3737
Exclude:
3838
- 'app/graphql/types/mutation_type.rb'
3939

40+
# Offense count: 2
41+
Lint/MissingSuper:
42+
Exclude:
43+
- 'app/services/execute_ranked_choice_signup_service.rb'
44+
- 'app/services/rerun_moderated_ranked_choice_signup_round_service.rb'
45+
4046
# Offense count: 1
4147
Lint/NoReturnInBeginEndBlocks:
4248
Exclude:
@@ -50,6 +56,7 @@ Metrics/BlockLength:
5056
- 'config/environments/production.rb'
5157
- 'config/initializers/doorkeeper.rb'
5258
- 'test/presenters/signup_count_presenter_test.rb'
59+
- 'test/services/rerun_moderated_ranked_choice_signup_round_service_test.rb'
5360

5461
# Offense count: 7
5562
# Configuration parameters: CountComments, Max, CountAsOne.
@@ -61,13 +68,27 @@ Metrics/ClassLength:
6168
- 'app/graphql/types/mutation_type.rb'
6269
- 'app/graphql/types/query_type.rb'
6370
- 'app/presenters/signup_count_presenter.rb'
71+
- 'app/services/execute_ranked_choice_signup_service.rb'
6472
- 'test/presenters/signup_count_presenter_test.rb'
6573

74+
# Offense count: 1
75+
# Configuration parameters: AllowedMethods, AllowedPatterns, Max.
76+
Metrics/CyclomaticComplexity:
77+
Exclude:
78+
- 'app/services/execute_ranked_choice_signup_service.rb'
79+
6680
# Offense count: 1
6781
# Configuration parameters: Max.
6882
Metrics/ParameterLists:
6983
Exclude:
7084
- 'app/presenters/signup_count_presenter.rb'
85+
- 'app/services/execute_ranked_choice_signup_service.rb'
86+
87+
# Offense count: 1
88+
# Configuration parameters: AllowedMethods, AllowedPatterns, Max.
89+
Metrics/PerceivedComplexity:
90+
Exclude:
91+
- 'app/services/execute_ranked_choice_signup_service.rb'
7192

7293
# Offense count: 1
7394
# Configuration parameters: ForbiddenPrefixes, AllowedMethods, MethodDefinitionMacros.

app/services/execute_ranked_choice_signup_service.rb

Lines changed: 15 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
# frozen_string_literal: true
12
class ExecuteRankedChoiceSignupService < CivilService::Service
23
class Result < CivilService::Result
34
attr_accessor :decision
@@ -46,6 +47,11 @@ def initialize(
4647
end
4748

4849
def inner_call
50+
# Capture this before doing anything else: accepting the choice changes its state, and
51+
# priority is a list position scoped by (user_con_profile, state), so the state change
52+
# renumbers the choice itself as well as the remaining pending choices
53+
after_signup_ranked_choice
54+
4955
skip = skip_reason
5056

5157
decision =
@@ -176,7 +182,14 @@ def do_waitlist
176182
end
177183

178184
def after_signup_ranked_choice
179-
@after_signup_ranked_choice ||=
180-
user_con_profile.signup_ranked_choices.where("priority > ?", signup_ranked_choice.priority).order(:priority).first
185+
return @after_signup_ranked_choice if defined?(@after_signup_ranked_choice)
186+
187+
@after_signup_ranked_choice =
188+
user_con_profile
189+
.signup_ranked_choices
190+
.where(state: "pending")
191+
.where("priority > ?", signup_ranked_choice.priority)
192+
.order(:priority)
193+
.first
181194
end
182195
end

app/services/rerun_moderated_ranked_choice_signup_round_service.rb

Lines changed: 24 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
# frozen_string_literal: true
12
class RerunModeratedRankedChoiceSignupRoundService < CivilService::Service
23
include SkippableAdvisoryLock
34

@@ -19,23 +20,38 @@ def inner_call
1920
private
2021

2122
def decisions_to_undo
23+
# Ordered by id so that choices are restored in the order they were executed; that way, if
24+
# multiple decisions for the same user are undone, restore_signup_ranked_choice can count on
25+
# earlier choices already being back in the pending list
2226
@decisions_to_undo ||=
23-
signup_round.ranked_choice_decisions.joins(:signup_request).where(signup_requests: { state: "pending" })
27+
signup_round
28+
.ranked_choice_decisions
29+
.joins(:signup_request)
30+
.where(signup_requests: { state: "pending" })
31+
.order(:id)
2432
end
2533

2634
def undo_decision(decision)
27-
signup_ranked_choice = decision.signup_ranked_choice
28-
signup_request = decision.signup_request
35+
restore_signup_ranked_choice(decision) if decision.signup_ranked_choice
36+
decision.signup_request.destroy!
37+
end
38+
39+
def restore_signup_ranked_choice(decision)
40+
after_signup_ranked_choice = decision.after_signup_ranked_choice
2941
priority =
3042
(
31-
if decision.after_signup_ranked_choice&.state == "pending"
32-
{ after: decision.after_signup_ranked_choice }
33-
else
43+
if after_signup_ranked_choice&.state == "pending"
44+
{ before: after_signup_ranked_choice }
45+
elsif after_signup_ranked_choice
46+
# The choice that followed this one was executed too; if its decision is also being
47+
# undone, it will be re-inserted after this one
3448
:first
49+
else
50+
# Nothing followed this choice when it was executed
51+
:last
3552
end
3653
)
3754

38-
signup_ranked_choice.update!(state: "pending", priority:)
39-
signup_request.destroy!
55+
decision.signup_ranked_choice.update!(state: "pending", priority:)
4056
end
4157
end
Lines changed: 147 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,147 @@
1+
# frozen_string_literal: true
2+
require "test_helper"
3+
4+
describe RerunModeratedRankedChoiceSignupRoundService do
5+
let(:convention) { create(:convention, :with_notification_templates, signup_mode: "moderated") }
6+
let(:signup_round) do
7+
create(:signup_round, convention:, ranked_choice_order: "asc", maximum_event_signups: "1", start: 1.day.ago)
8+
end
9+
let(:one_player_registration_policy) do
10+
RegistrationPolicy.new({ buckets: [{ key: "only_one_player", total_slots: 1, slots_limited: true }] })
11+
end
12+
13+
def create_ranked_choices(user_con_profile, count)
14+
(1..count).map do |i|
15+
event = create(:event, convention:, registration_policy: one_player_registration_policy)
16+
run = create(:run, event:, starts_at: convention.starts_at + (i * 5).hours)
17+
create(:signup_ranked_choice, user_con_profile:, target_run: run)
18+
end
19+
end
20+
21+
def execute_round
22+
ExecuteRankedChoiceSignupRoundService.new(signup_round:, whodunit: nil).call!
23+
end
24+
25+
def rerun_round
26+
max_decision_id = RankedChoiceDecision.maximum(:id) || 0
27+
result = RerunModeratedRankedChoiceSignupRoundService.new(signup_round:, whodunit: nil).call!
28+
assert result.success?
29+
RankedChoiceDecision.where("id > ?", max_decision_id).order(:id).to_a
30+
end
31+
32+
def pending_choice_ids(user_con_profile)
33+
user_con_profile.signup_ranked_choices.where(state: "pending").order(:priority).pluck(:id)
34+
end
35+
36+
it "records the next pending choice as after_signup_ranked_choice when executing" do
37+
user_con_profile = create(:user_con_profile, convention:, lottery_number: 1, ranked_choice_fallback_action: "none")
38+
choices = create_ranked_choices(user_con_profile, 3)
39+
40+
result = execute_round
41+
42+
signup_decision = result.decisions.find { |decision| decision.decision == "signup" }
43+
assert_equal choices[0], signup_decision.signup_ranked_choice
44+
assert_equal choices[1], signup_decision.after_signup_ranked_choice
45+
end
46+
47+
it "re-requests the same choice for attendees whose requests were still pending" do
48+
rejected_user = create(:user_con_profile, convention:, lottery_number: 1, ranked_choice_fallback_action: "none")
49+
pending_user = create(:user_con_profile, convention:, lottery_number: 2, ranked_choice_fallback_action: "none")
50+
rejected_user_choices = create_ranked_choices(rejected_user, 2)
51+
pending_user_choices = create_ranked_choices(pending_user, 3)
52+
53+
execute_round
54+
rejected_user_choices[0].reload.result_signup_request.update!(state: "rejected")
55+
original_pending_request = pending_user_choices[0].reload.result_signup_request
56+
57+
rerun_round
58+
59+
# The attendee with the rejected request should advance to their second choice
60+
assert_equal "requested", rejected_user_choices[1].reload.state
61+
assert_equal "pending", rejected_user_choices[1].result_signup_request.state
62+
63+
# The attendee whose request was still pending should have the same choice re-requested
64+
assert_equal "requested", pending_user_choices[0].reload.state
65+
new_request = pending_user_choices[0].result_signup_request
66+
assert_equal "pending", new_request.state
67+
assert_not_equal original_pending_request.id, new_request.id
68+
assert_equal pending_user_choices[0].target_run, new_request.target_run
69+
assert_equal [pending_user_choices[1].id, pending_user_choices[2].id], pending_choice_ids(pending_user)
70+
end
71+
72+
it "does not advance attendees with pending requests across multiple reruns" do
73+
rejected_user = create(:user_con_profile, convention:, lottery_number: 1, ranked_choice_fallback_action: "none")
74+
pending_user = create(:user_con_profile, convention:, lottery_number: 2, ranked_choice_fallback_action: "none")
75+
rejected_user_choices = create_ranked_choices(rejected_user, 3)
76+
pending_user_choices = create_ranked_choices(pending_user, 3)
77+
78+
execute_round
79+
rejected_user_choices[0].reload.result_signup_request.update!(state: "rejected")
80+
rerun_round
81+
rejected_user_choices[1].reload.result_signup_request.update!(state: "rejected")
82+
rerun_round
83+
84+
assert_equal "requested", rejected_user_choices[2].reload.state
85+
assert_equal "pending", rejected_user_choices[2].result_signup_request.state
86+
87+
assert_equal "requested", pending_user_choices[0].reload.state
88+
assert_equal "pending", pending_user_choices[0].result_signup_request.state
89+
assert_equal pending_user_choices[0].target_run, pending_user_choices[0].result_signup_request.target_run
90+
assert_equal [pending_user_choices[1].id, pending_user_choices[2].id], pending_choice_ids(pending_user)
91+
end
92+
93+
it "restores a choice that was the last pending choice to the end of the list" do
94+
user_con_profile = create(:user_con_profile, convention:, lottery_number: 1, ranked_choice_fallback_action: "none")
95+
choices = create_ranked_choices(user_con_profile, 2)
96+
create(:signup, run: choices[0].target_run, state: "confirmed")
97+
98+
result = execute_round
99+
original_decisions = result.decisions.select { |decision| decision.user_con_profile == user_con_profile }
100+
assert_equal %w[skip_choice signup], original_decisions.map(&:decision)
101+
102+
rerun_decisions = rerun_round.select { |decision| decision.user_con_profile == user_con_profile }
103+
104+
# The full first choice should still be considered (and skipped) before the second choice
105+
# is re-requested
106+
assert_equal %w[skip_choice signup], rerun_decisions.map(&:decision)
107+
assert_equal choices[0], rerun_decisions[0].signup_ranked_choice
108+
assert_equal choices[1], rerun_decisions[1].signup_ranked_choice
109+
assert_equal [choices[0].id], pending_choice_ids(user_con_profile)
110+
end
111+
112+
it "skips restoring choices that have been deleted" do
113+
user_con_profile = create(:user_con_profile, convention:, lottery_number: 1, ranked_choice_fallback_action: "none")
114+
choices = create_ranked_choices(user_con_profile, 2)
115+
116+
execute_round
117+
original_request = choices[0].reload.result_signup_request
118+
choices[0].destroy!
119+
120+
rerun_round
121+
122+
assert_not SignupRequest.exists?(original_request.id)
123+
assert_equal "requested", choices[1].reload.state
124+
assert_equal "pending", choices[1].result_signup_request.state
125+
end
126+
127+
describe "with a round allowing multiple signups" do
128+
let(:signup_round) do
129+
create(:signup_round, convention:, ranked_choice_order: "asc", maximum_event_signups: "2", start: 1.day.ago)
130+
end
131+
132+
it "restores multiple undone choices in their original order" do
133+
user_con_profile =
134+
create(:user_con_profile, convention:, lottery_number: 1, ranked_choice_fallback_action: "none")
135+
choices = create_ranked_choices(user_con_profile, 3)
136+
137+
execute_round
138+
assert_equal(%w[requested requested pending], choices.map { |choice| choice.reload.state })
139+
140+
rerun_decisions = rerun_round
141+
142+
assert_equal [choices[0], choices[1]], rerun_decisions.map(&:signup_ranked_choice)
143+
assert_equal(%w[requested requested pending], choices.map { |choice| choice.reload.state })
144+
assert_equal [choices[2].id], pending_choice_ids(user_con_profile)
145+
end
146+
end
147+
end

0 commit comments

Comments
 (0)