Add backend lifecycle E2E coverage

This commit is contained in:
Daniel Schädler 2026-04-11 13:12:49 +02:00
parent 98a5c922f2
commit 71be61791a
3 changed files with 340 additions and 0 deletions

View File

@ -24,3 +24,17 @@ rails spec:
paths: paths:
- specs_with_runtime.txt - specs_with_runtime.txt
expire_in: 30 days 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

View File

@ -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.

View File

@ -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