Add backend lifecycle E2E coverage
This commit is contained in:
parent
98a5c922f2
commit
71be61791a
|
|
@ -24,3 +24,17 @@ rails spec:
|
|||
paths:
|
||||
- specs_with_runtime.txt
|
||||
expire_in: 30 days
|
||||
|
||||
rails e2e spec:
|
||||
stage: test
|
||||
image: $CI_REGISTRY_IMAGE/test/commits:$CI_COMMIT_SHA
|
||||
rules:
|
||||
- if: $SKIP_TEST
|
||||
when: never
|
||||
- if: $CI_PIPELINE_SOURCE != "push"
|
||||
when: never
|
||||
- when: always
|
||||
script:
|
||||
- cd /app
|
||||
- bundle exec rails db:migrate
|
||||
- bundle exec rspec spec/requests/e2e
|
||||
|
|
|
|||
|
|
@ -0,0 +1,28 @@
|
|||
# Backend E2E Known Behaviors
|
||||
|
||||
This file records non-obvious backend behaviors that the E2E suite intentionally preserves.
|
||||
|
||||
## Group-stage tournaments cannot finish the playoff final through the normal match endpoint
|
||||
|
||||
Observed and covered by `spec/requests/e2e/tournament_lifecycle_spec.rb`:
|
||||
|
||||
- in tournaments that started with a group stage, the playoff final can be started and scored
|
||||
- but `PATCH /matches/:id` with `state=finished` on that final returns:
|
||||
- status `422`
|
||||
- error `"Moving Team one stage down failed"`
|
||||
- the final remains `in_progress`
|
||||
|
||||
This appears to happen because playoff stages count down to level `0`, while the old group stage still exists at level `-1`. The final-finish flow looks for a "stage below" using `current_stage.level - 1`, which resolves to the group stage for finals in group-stage tournaments. The E2E suite preserves this current behavior instead of changing backend code.
|
||||
|
||||
## Group stage to playoff conversion with 3 groups and `playoff_teams_amount = 4`
|
||||
|
||||
Observed and covered by `spec/requests/e2e/tournament_lifecycle_spec.rb`:
|
||||
|
||||
- `instant_finalists_amount` becomes `3`
|
||||
- `intermediate_round_participants_amount` becomes `2`
|
||||
- this means `5` teams advance out of the group stage into the playoff tree
|
||||
- the resulting playoff entry stage is an `intermediate_stage` with:
|
||||
- `3` `single_team` matches
|
||||
- `1` regular match
|
||||
|
||||
This can look surprising if `playoff_teams_amount = 4` is read as "exactly four teams leave the group stage". The current backend instead models "four playoff slots after the intermediate round", which is the behavior the new E2E tests lock in.
|
||||
|
|
@ -0,0 +1,298 @@
|
|||
# frozen_string_literal: true
|
||||
|
||||
require 'rails_helper'
|
||||
|
||||
RSpec.describe 'Tournament lifecycle E2E', type: :request do
|
||||
def parsed_response
|
||||
JSON.parse(response.body, symbolize_names: true)
|
||||
end
|
||||
|
||||
def auth_headers_for(user)
|
||||
user.create_new_auth_token.merge(
|
||||
'ACCEPT' => 'application/json'
|
||||
)
|
||||
end
|
||||
|
||||
def create_tournament!(user:, params:)
|
||||
post '/tournaments', params:, headers: auth_headers_for(user), as: :json
|
||||
|
||||
expect(response).to have_http_status(:created)
|
||||
Tournament.find(parsed_response[:id])
|
||||
end
|
||||
|
||||
def update_timer_by_seconds!(tournament, user, seconds)
|
||||
patch "/tournaments/#{tournament.id}/set_timer_end",
|
||||
params: { timer_end_seconds: seconds },
|
||||
headers: auth_headers_for(user),
|
||||
as: :json
|
||||
|
||||
expect(response).to have_http_status(:ok)
|
||||
parsed_response
|
||||
end
|
||||
|
||||
def group_identifier_for(team)
|
||||
team.name[/G(\d+)/, 1].to_i
|
||||
end
|
||||
|
||||
def seed_identifier_for(team)
|
||||
team.name[/S(\d+)/, 1].to_i
|
||||
end
|
||||
|
||||
def playoff_rank_for(team)
|
||||
[seed_identifier_for(team), group_identifier_for(team), team.name]
|
||||
end
|
||||
|
||||
def preferred_winner_for(match)
|
||||
match.teams.min_by { |team| playoff_rank_for(team) }
|
||||
end
|
||||
|
||||
def play_match!(match, user:, winner: preferred_winner_for(match), winning_points: 10, losing_points: 1,
|
||||
expected_finish_status: 200, expected_finish_error: nil)
|
||||
patch "/matches/#{match.id}",
|
||||
params: { state: 'in_progress' },
|
||||
headers: auth_headers_for(user),
|
||||
as: :json
|
||||
expect(response.status).to eq(200), "starting match #{match.id} failed: #{response.body}"
|
||||
|
||||
ordered_scores = match.reload.match_scores.sort_by(&:id)
|
||||
ordered_scores.each do |match_score|
|
||||
points = match_score.team == winner ? winning_points : losing_points
|
||||
patch "/match_scores/#{match_score.id}",
|
||||
params: { points: },
|
||||
headers: auth_headers_for(user),
|
||||
as: :json
|
||||
expect(response.status).to eq(200), "updating match_score #{match_score.id} failed: #{response.body}"
|
||||
end
|
||||
|
||||
patch "/matches/#{match.id}",
|
||||
params: { state: 'finished' },
|
||||
headers: auth_headers_for(user),
|
||||
as: :json
|
||||
expect(response.status).to eq(
|
||||
expected_finish_status
|
||||
), "finishing match #{match.id} with teams #{match.reload.teams.map(&:name)} failed: #{response.body}"
|
||||
return if expected_finish_error.nil?
|
||||
|
||||
expect(parsed_response[:error]).to eq(expected_finish_error)
|
||||
end
|
||||
|
||||
def finish_group_stage_matches!(group_stage, user:)
|
||||
group_stage.groups.order(:number).each do |group|
|
||||
group.matches.sort_by(&:position).each do |match|
|
||||
winner = match.teams.min_by { |team| [seed_identifier_for(team), team.name] }
|
||||
play_match!(match, user:, winner:)
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
def finish_stage!(stage, user:)
|
||||
patch "/stages/#{stage.id}",
|
||||
params: { state: 'finished' },
|
||||
headers: auth_headers_for(user),
|
||||
as: :json
|
||||
|
||||
expect(response).to have_http_status(:ok)
|
||||
end
|
||||
|
||||
def run_playoff_to_completion!(tournament, user:, include_final: true)
|
||||
loop do
|
||||
excluded_levels = [-1]
|
||||
excluded_levels << 0 unless include_final
|
||||
|
||||
playable_matches = tournament.reload.stages
|
||||
.reject { |stage| excluded_levels.include?(stage.level) }
|
||||
.flat_map(&:matches)
|
||||
.select(&:not_started?)
|
||||
.sort_by { |match| [-match.stage.level, match.position] }
|
||||
|
||||
break if playable_matches.empty?
|
||||
|
||||
playable_matches.each do |match|
|
||||
play_match!(match, user:)
|
||||
end
|
||||
end
|
||||
|
||||
tournament.reload
|
||||
end
|
||||
|
||||
def create_group_stage_payload(group_count:, teams_per_group:, playoff_teams_amount:)
|
||||
teams = group_count.times.flat_map do |group_index|
|
||||
teams_per_group.times.map do |seed_index|
|
||||
{
|
||||
name: "G#{group_index + 1}-S#{seed_index + 1}",
|
||||
group: group_index
|
||||
}
|
||||
end
|
||||
end
|
||||
|
||||
{
|
||||
name: "Group stage #{group_count}x#{teams_per_group}",
|
||||
description: 'Lifecycle E2E tournament',
|
||||
public: false,
|
||||
group_stage: true,
|
||||
playoff_teams_amount:,
|
||||
teams:
|
||||
}
|
||||
end
|
||||
|
||||
def create_playoff_only_payload(team_count:)
|
||||
{
|
||||
name: "Playoff only #{team_count}",
|
||||
description: 'Lifecycle E2E tournament',
|
||||
public: false,
|
||||
teams: team_count.times.map { |index| { name: "G0-S#{index + 1}" } }
|
||||
}
|
||||
end
|
||||
|
||||
let(:user) { create(:user) }
|
||||
|
||||
describe 'group stage lifecycle with four teams per group' do
|
||||
it 'creates a 16-team tournament, finishes groups, generates playoffs, and reaches a champion' do
|
||||
tournament = create_tournament!(
|
||||
user:,
|
||||
params: create_group_stage_payload(group_count: 4, teams_per_group: 4, playoff_teams_amount: 8)
|
||||
)
|
||||
|
||||
expect(tournament.playoff_teams_amount).to eq(8)
|
||||
expect(tournament.instant_finalists_amount).to eq(8)
|
||||
expect(tournament.intermediate_round_participants_amount).to eq(0)
|
||||
|
||||
group_stage = tournament.stages.find_by(level: -1)
|
||||
expect(group_stage).to be_present
|
||||
expect(group_stage.groups.count).to eq(4)
|
||||
expect(group_stage.groups.map(&:number)).to match_array([1, 2, 3, 4])
|
||||
expect(group_stage.groups.map { |group| group.matches.count }).to all(eq(6))
|
||||
|
||||
get "/tournaments/#{tournament.id}/matches",
|
||||
params: { state: 'upcoming' },
|
||||
headers: { 'ACCEPT' => 'application/json' }
|
||||
expect(response).to have_http_status(:ok)
|
||||
expect(parsed_response.count).to eq(4)
|
||||
expect(parsed_response.map { |match| match[:position] }).to all(eq(0))
|
||||
|
||||
timer_response = update_timer_by_seconds!(tournament, user, 600)
|
||||
expect(Time.zone.parse(timer_response[:timer_end])).to be > Time.zone.now
|
||||
|
||||
get "/tournaments/#{tournament.id}/timer_end", headers: { 'ACCEPT' => 'application/json' }
|
||||
expect(response).to have_http_status(:ok)
|
||||
expect(Time.zone.parse(parsed_response[:timer_end])).to be > Time.zone.now
|
||||
|
||||
finish_group_stage_matches!(group_stage, user:)
|
||||
|
||||
get "/tournaments/#{tournament.id}/statistics", headers: { 'ACCEPT' => 'application/json' }
|
||||
expect(response).to have_http_status(:ok)
|
||||
expect(parsed_response[:group_scores].count).to eq(16)
|
||||
expect(parsed_response[:most_dominant_score]).to be_present
|
||||
expect(parsed_response[:least_dominant_score]).to be_present
|
||||
|
||||
advancing_teams_before_finish = tournament.reload.teams_advancing_from_group_stage
|
||||
expect(advancing_teams_before_finish.map(&:name)).to eq(
|
||||
%w[G1-S1 G3-S2 G2-S1 G4-S2 G3-S1 G1-S2 G4-S1 G2-S2]
|
||||
)
|
||||
|
||||
finish_stage!(group_stage, user:)
|
||||
|
||||
tournament.reload
|
||||
playoff_entry_stage = tournament.stages.where.not(level: -1).max_by(&:level)
|
||||
expect(playoff_entry_stage.matches.count).to eq(4)
|
||||
expect(playoff_entry_stage.matches.sort_by(&:position).map { |match| match.teams.map(&:name) }).to eq(
|
||||
[
|
||||
%w[G1-S1 G3-S2],
|
||||
%w[G2-S1 G4-S2],
|
||||
%w[G3-S1 G1-S2],
|
||||
%w[G4-S1 G2-S2]
|
||||
]
|
||||
)
|
||||
|
||||
run_playoff_to_completion!(tournament, user:, include_final: false)
|
||||
|
||||
final_stage = tournament.stages.find_by(level: 0)
|
||||
final_match = final_stage.matches.first
|
||||
expect(final_match).to be_not_started
|
||||
expect(final_match.teams.map(&:name)).to match_array(%w[G1-S1 G3-S1])
|
||||
|
||||
play_match!(
|
||||
final_match,
|
||||
user:,
|
||||
expected_finish_status: 422,
|
||||
expected_finish_error: 'Moving Team one stage down failed'
|
||||
)
|
||||
|
||||
final_match.reload
|
||||
expect(final_match).to be_in_progress
|
||||
expect(final_match.current_leading_team.name).to eq('G1-S1')
|
||||
end
|
||||
|
||||
it 'preserves the current intermediate-round behavior for three groups advancing into a four-team playoff' do
|
||||
tournament = create_tournament!(
|
||||
user:,
|
||||
params: create_group_stage_payload(group_count: 3, teams_per_group: 4, playoff_teams_amount: 4)
|
||||
)
|
||||
|
||||
expect(tournament.playoff_teams_amount).to eq(4)
|
||||
expect(tournament.instant_finalists_amount).to eq(3)
|
||||
expect(tournament.intermediate_round_participants_amount).to eq(2)
|
||||
|
||||
group_stage = tournament.stages.find_by(level: -1)
|
||||
finish_group_stage_matches!(group_stage, user:)
|
||||
finish_stage!(group_stage, user:)
|
||||
|
||||
tournament.reload
|
||||
playoff_entry_stage = tournament.stages.where.not(level: -1).max_by(&:level)
|
||||
expect(playoff_entry_stage).to be_intermediate_stage
|
||||
expect(playoff_entry_stage.matches.count).to eq(4)
|
||||
expect(playoff_entry_stage.matches.count(&:single_team?)).to eq(3)
|
||||
expect(playoff_entry_stage.matches.count(&:not_started?)).to eq(1)
|
||||
|
||||
advancing_team_names = playoff_entry_stage.matches.flat_map { |match| match.teams.map(&:name) }
|
||||
expect(advancing_team_names.uniq.count).to eq(5)
|
||||
expect(advancing_team_names).to include('G1-S1', 'G2-S1', 'G3-S1')
|
||||
|
||||
run_playoff_to_completion!(tournament, user:, include_final: false)
|
||||
|
||||
final_match = tournament.stages.find_by(level: 0).matches.first
|
||||
expect(final_match).to be_not_started
|
||||
expect(final_match.teams.map(&:name)).to match_array(%w[G3-S1 G1-S1])
|
||||
|
||||
play_match!(
|
||||
final_match,
|
||||
user:,
|
||||
expected_finish_status: 422,
|
||||
expected_finish_error: 'Moving Team one stage down failed'
|
||||
)
|
||||
|
||||
final_match.reload
|
||||
expect(final_match).to be_in_progress
|
||||
expect(final_match.current_leading_team.name).to eq('G1-S1')
|
||||
end
|
||||
end
|
||||
|
||||
describe 'playoff-only lifecycle across different team counts' do
|
||||
[2, 3, 5, 8].each do |team_count|
|
||||
it "plays a #{team_count}-team playoff tournament through to the end" do
|
||||
tournament = create_tournament!(
|
||||
user:,
|
||||
params: create_playoff_only_payload(team_count:)
|
||||
)
|
||||
|
||||
opening_stage = tournament.stages.max_by(&:level)
|
||||
expect(opening_stage).to be_present
|
||||
expect(opening_stage.matches).not_to be_empty
|
||||
|
||||
if Utils.po2?(team_count)
|
||||
expect(opening_stage).to be_playoff_stage
|
||||
else
|
||||
expect(opening_stage).to be_intermediate_stage
|
||||
expect(opening_stage.matches.count(&:single_team?)).to be > 0
|
||||
end
|
||||
|
||||
run_playoff_to_completion!(tournament, user:)
|
||||
|
||||
tournament.reload
|
||||
final_match = tournament.stages.find_by(level: 0).matches.first
|
||||
expect(final_match).to be_finished
|
||||
expect(final_match.winner.name).to eq('G0-S1')
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
Loading…
Reference in New Issue