Run backend E2E over HTTP

This commit is contained in:
Daniel Schädler 2026-04-11 13:40:12 +02:00
parent 71be61791a
commit 39ba1cff22
8 changed files with 663 additions and 301 deletions

View File

@ -37,4 +37,11 @@ rails e2e spec:
script: script:
- cd /app - cd /app
- bundle exec rails db:migrate - bundle exec rails db:migrate
- bundle exec rspec spec/requests/e2e - bundle exec rails runner -e test "user = User.find_or_initialize_by(email: 'e2e@example.com'); user.username = 'e2e-user'; user.password = 'password123'; user.password_confirmation = 'password123'; user.confirmed_at = Time.current; user.uid = user.email; user.provider = 'email'; user.save!"
- bundle exec rails s -e test -p 3000 -b 0.0.0.0 > /tmp/turniere-e2e-server.log 2>&1 &
- SERVER_PID=$!
- ruby script/wait_for_http.rb http://127.0.0.1:3000 60
- TURNIERE_E2E_BASE_URL=http://127.0.0.1:3000 TURNIERE_E2E_EMAIL=e2e@example.com TURNIERE_E2E_PASSWORD=password123 TURNIERE_E2E_USERNAME=e2e-user bundle exec rspec spec/e2e/http
- kill $SERVER_PID
after_script:
- test -f /tmp/turniere-e2e-server.log && tail -n 200 /tmp/turniere-e2e-server.log || true

123
e2e/lib/api_client.rb Normal file
View File

@ -0,0 +1,123 @@
# frozen_string_literal: true
require 'json'
require 'net/http'
require 'uri'
module TurniereE2E
class ApiError < StandardError
attr_reader :status, :body
def initialize(message, status:, body:)
super(message)
@status = status
@body = body
end
end
class ApiClient
attr_reader :base_url
def initialize(base_url:)
@base_url = base_url.sub(%r{/\z}, '')
@auth_headers = {}
end
def register!(email:, password:, username:)
response = request(
:post,
'/users',
body: {
email: email,
password: password,
password_confirmation: password,
username: username
}
)
store_auth_headers!(response)
response.fetch(:json)
end
def login!(email:, password:)
response = request(
:post,
'/users/sign_in',
body: {
email: email,
password: password
}
)
store_auth_headers!(response)
response
end
def authenticated?
!@auth_headers.empty?
end
def get(path, params: nil, headers: {})
request(:get, path, params: params, headers: headers)
end
def post(path, body: nil, params: nil, headers: {})
request(:post, path, body: body, params: params, headers: headers)
end
def patch(path, body: nil, params: nil, headers: {})
request(:patch, path, body: body, params: params, headers: headers)
end
private
def request(method, path, body: nil, params: nil, headers: {})
uri = URI.parse("#{base_url}#{path}")
uri.query = URI.encode_www_form(params) if params && !params.empty?
request_class = case method
when :get then Net::HTTP::Get
when :post then Net::HTTP::Post
when :patch then Net::HTTP::Patch
else
raise ArgumentError, "unsupported method #{method}"
end
request = request_class.new(uri)
merged_headers(headers).each { |key, value| request[key] = value }
request.body = JSON.dump(body) if body
response = Net::HTTP.start(uri.host, uri.port, use_ssl: uri.scheme == 'https') do |http|
http.request(request)
end
parsed_json = parse_json_body(response.body)
{
status: response.code.to_i,
json: parsed_json,
headers: response.each_header.to_h
}
end
def merged_headers(headers)
{
'accept' => 'application/json',
'content-type' => 'application/json'
}.merge(@auth_headers).merge(headers.transform_keys(&:downcase))
end
def parse_json_body(body)
return {} if body.nil? || body.strip.empty?
JSON.parse(body, symbolize_names: true)
rescue JSON::ParserError
{ raw_body: body }
end
def store_auth_headers!(response)
%w[access-token client uid].each_with_object({}) do |key, auth_headers|
auth_headers[key] = response[:headers][key] if response[:headers].key?(key)
end.tap do |headers|
@auth_headers = headers unless headers.empty?
end
end
end
end

390
e2e/lib/scenario_runner.rb Normal file
View File

@ -0,0 +1,390 @@
# frozen_string_literal: true
require_relative 'api_client'
require 'securerandom'
require 'time'
module TurniereE2E
class ScenarioRunner
DEFAULT_PASSWORD = 'password123'.freeze
FINAL_FINISH_ERROR = 'Moving Team one stage down failed'
attr_reader :client
def initialize(base_url:, email: nil, password: nil, username: nil)
@client = ApiClient.new(base_url: base_url)
@email = email
@password = password
@username = username
end
def run_group_stage_main_usecase(stop_at: :final_finish_attempted)
session = register_session!
tournament = create_tournament!(
create_group_stage_payload(group_count: 4, teams_per_group: 4, playoff_teams_amount: 8)
)
checkpoints = {
created: summarize_tournament(tournament)
}
timer_response = patch!(
"/tournaments/#{tournament.fetch(:id)}/set_timer_end",
body: { timer_end_seconds: 600 }
)
checkpoints[:timer_set] = { timer_end: timer_response.fetch(:timer_end) }
return result(session: session, tournament: tournament, checkpoints: checkpoints, stop_at: stop_at) if stop_at == :timer_set
upcoming_matches = get!(
"/tournaments/#{tournament.fetch(:id)}/matches",
params: { state: 'upcoming' }
)
checkpoints[:upcoming_group_matches] = { match_ids: upcoming_matches.map { |match| match.fetch(:id) } }
return result(session: session, tournament: tournament, checkpoints: checkpoints, stop_at: stop_at) if stop_at == :upcoming_group_matches
finish_group_stage_matches!(tournament)
tournament = fetch_tournament!(tournament.fetch(:id))
checkpoints[:groups_finished] = summarize_tournament(tournament)
return result(session: session, tournament: tournament, checkpoints: checkpoints, stop_at: stop_at) if stop_at == :groups_finished
statistics = get!("/tournaments/#{tournament.fetch(:id)}/statistics")
checkpoints[:statistics] = {
group_scores_count: statistics.fetch(:group_scores).count,
most_dominant_score_id: statistics.fetch(:most_dominant_score).fetch(:id),
least_dominant_score_id: statistics.fetch(:least_dominant_score).fetch(:id)
}
finish_stage!(group_stage_of!(tournament).fetch(:id))
tournament = fetch_tournament!(tournament.fetch(:id))
checkpoints[:playoffs_created] = summarize_tournament(tournament)
return result(session: session, tournament: tournament, checkpoints: checkpoints, stop_at: stop_at) if stop_at == :playoffs_created
run_playoffs_until_before_final!(tournament.fetch(:id))
tournament = fetch_tournament!(tournament.fetch(:id))
checkpoints[:before_final_finish_attempt] = summarize_tournament(tournament)
return result(session: session, tournament: tournament, checkpoints: checkpoints, stop_at: stop_at) if stop_at == :before_final_finish_attempt
final_match = final_match_of!(tournament)
finish_attempt = play_match!(
final_match,
winner_name: preferred_playoff_winner_name(final_match),
expected_finish_status: 422,
expected_finish_error: FINAL_FINISH_ERROR
)
tournament = fetch_tournament!(tournament.fetch(:id))
checkpoints[:final_finish_attempted] = {
finish_attempt: finish_attempt,
tournament: summarize_tournament(tournament)
}
result(session: session, tournament: tournament, checkpoints: checkpoints, stop_at: stop_at)
end
def run_group_stage_intermediate_round(stop_at: :final_finish_attempted)
session = register_session!
tournament = create_tournament!(
create_group_stage_payload(group_count: 3, teams_per_group: 4, playoff_teams_amount: 4)
)
checkpoints = {
created: summarize_tournament(tournament)
}
return result(session: session, tournament: tournament, checkpoints: checkpoints, stop_at: stop_at) if stop_at == :created
finish_group_stage_matches!(tournament)
finish_stage!(group_stage_of!(fetch_tournament!(tournament.fetch(:id))).fetch(:id))
tournament = fetch_tournament!(tournament.fetch(:id))
checkpoints[:playoffs_created] = summarize_tournament(tournament)
return result(session: session, tournament: tournament, checkpoints: checkpoints, stop_at: stop_at) if stop_at == :playoffs_created
run_playoffs_until_before_final!(tournament.fetch(:id))
tournament = fetch_tournament!(tournament.fetch(:id))
checkpoints[:before_final_finish_attempt] = summarize_tournament(tournament)
return result(session: session, tournament: tournament, checkpoints: checkpoints, stop_at: stop_at) if stop_at == :before_final_finish_attempt
final_match = final_match_of!(tournament)
finish_attempt = play_match!(
final_match,
winner_name: preferred_playoff_winner_name(final_match),
expected_finish_status: 422,
expected_finish_error: FINAL_FINISH_ERROR
)
tournament = fetch_tournament!(tournament.fetch(:id))
checkpoints[:final_finish_attempted] = {
finish_attempt: finish_attempt,
tournament: summarize_tournament(tournament)
}
result(session: session, tournament: tournament, checkpoints: checkpoints, stop_at: stop_at)
end
def run_playoff_only(team_count:, stop_at: :completed)
session = register_session!
tournament = create_tournament!(create_playoff_only_payload(team_count: team_count))
checkpoints = {
created: summarize_tournament(tournament)
}
return result(session: session, tournament: tournament, checkpoints: checkpoints, stop_at: stop_at) if stop_at == :created
run_playoffs_until_complete!(tournament.fetch(:id))
tournament = fetch_tournament!(tournament.fetch(:id))
checkpoints[:completed] = summarize_tournament(tournament)
result(session: session, tournament: tournament, checkpoints: checkpoints, stop_at: stop_at)
end
def wait_for_healthcheck!(timeout_seconds: 30)
started_at = Time.now
loop do
begin
response = client.get('/healthz')
return response.fetch(:json) if response.fetch(:status) == 200
rescue Errno::ECONNREFUSED, Errno::EHOSTUNREACH
nil
end
raise "healthcheck did not become ready within #{timeout_seconds}s" if Time.now - started_at > timeout_seconds
sleep 0.5
end
end
private
def register_session!
return login_with_existing_credentials! if @email && @password
unique = SecureRandom.hex(6)
email = "e2e-#{unique}@example.com"
username = "e2e-#{unique}"
response = client.register!(email: email, password: DEFAULT_PASSWORD, username: username)
return { email: email, username: username } if client.authenticated?
login_response = client.login!(email: email, password: DEFAULT_PASSWORD)
return { email: email, username: username } if login_response.fetch(:status) == 200 && client.authenticated?
raise ApiError.new(
'authentication bootstrap failed; provide a confirmed E2E user via TURNIERE_E2E_EMAIL and TURNIERE_E2E_PASSWORD',
status: login_response.fetch(:status),
body: login_response.fetch(:json)
)
end
def create_tournament!(payload)
response = client.post('/tournaments', body: payload)
expect_status!(response, 201, 'create tournament')
fetch_tournament!(response.fetch(:json).fetch(:id))
end
def fetch_tournament!(tournament_id)
response = client.get("/tournaments/#{tournament_id}")
expect_status!(response, 200, "fetch tournament #{tournament_id}")
response.fetch(:json)
end
def get!(path, params: nil)
response = client.get(path, params: params)
expect_status!(response, 200, "get #{path}")
response.fetch(:json)
end
def patch!(path, body:)
response = client.patch(path, body: body)
expect_status!(response, 200, "patch #{path}")
response.fetch(:json)
end
def finish_group_stage_matches!(tournament)
group_stage_of!(tournament).fetch(:groups).sort_by { |group| group.fetch(:number) }.each do |group|
group.fetch(:matches).sort_by { |match| match.fetch(:position) }.each do |match|
play_match!(match, winner_name: preferred_group_winner_name(match))
end
end
end
def run_playoffs_until_before_final!(tournament_id)
loop do
tournament = fetch_tournament!(tournament_id)
playable_matches = playoff_matches(tournament).reject { |match| match.fetch(:stage_level) == 0 }
.select { |match| match.fetch(:state) == 'not_started' }
.sort_by { |match| [-match.fetch(:stage_level), match.fetch(:position)] }
break if playable_matches.empty?
playable_matches.each do |match|
play_match!(match, winner_name: preferred_playoff_winner_name(match))
end
end
end
def run_playoffs_until_complete!(tournament_id)
loop do
tournament = fetch_tournament!(tournament_id)
playable_matches = playoff_matches(tournament).select { |match| match.fetch(:state) == 'not_started' }
.sort_by { |match| [-match.fetch(:stage_level), match.fetch(:position)] }
break if playable_matches.empty?
playable_matches.each do |match|
play_match!(match, winner_name: preferred_playoff_winner_name(match))
end
end
end
def play_match!(match, winner_name:, expected_finish_status: 200, expected_finish_error: nil)
started = client.patch("/matches/#{match.fetch(:id)}", body: { state: 'in_progress' })
expect_status!(started, 200, "start match #{match.fetch(:id)}")
refreshed_match = fetch_match!(match.fetch(:id))
refreshed_match.fetch(:match_scores).sort_by { |score| score.fetch(:id) }.each do |score|
points = score.dig(:team, :name) == winner_name ? 10 : 1
updated_score = client.patch("/match_scores/#{score.fetch(:id)}", body: { points: points })
expect_status!(updated_score, 200, "update match_score #{score.fetch(:id)}")
end
finished = client.patch("/matches/#{match.fetch(:id)}", body: { state: 'finished' })
expect_status!(finished, expected_finish_status, "finish match #{match.fetch(:id)}")
if expected_finish_error
actual_error = finished.fetch(:json).fetch(:error)
raise "expected finish error #{expected_finish_error.inspect}, got #{actual_error.inspect}" unless actual_error == expected_finish_error
end
{
match_id: match.fetch(:id),
status: finished.fetch(:status),
body: finished.fetch(:json)
}
end
def fetch_match!(match_id)
response = client.get("/matches/#{match_id}")
expect_status!(response, 200, "fetch match #{match_id}")
response.fetch(:json)
end
def finish_stage!(stage_id)
response = client.patch("/stages/#{stage_id}", body: { state: 'finished' })
expect_status!(response, 200, "finish stage #{stage_id}")
response.fetch(:json)
end
def group_stage_of!(tournament)
tournament.fetch(:stages).find { |stage| stage.fetch(:level) == -1 } || raise('group stage not found')
end
def final_match_of!(tournament)
final_stage = tournament.fetch(:stages).find { |stage| stage.fetch(:level) == 0 } || raise('final stage not found')
attach_stage_level(final_stage).fetch(:matches).first
end
def playoff_matches(tournament)
tournament.fetch(:stages)
.reject { |stage| stage.fetch(:level) == -1 }
.flat_map { |stage| attach_stage_level(stage).fetch(:matches) }
end
def attach_stage_level(stage)
stage.merge(matches: stage.fetch(:matches).map { |match| match.merge(stage_level: stage.fetch(:level)) })
end
def preferred_group_winner_name(match)
match.fetch(:match_scores)
.map { |score| score.dig(:team, :name) }
.min_by { |name| [seed_identifier_for(name), name] }
end
def preferred_playoff_winner_name(match)
match.fetch(:match_scores)
.map { |score| score.dig(:team, :name) }
.min_by { |name| [seed_identifier_for(name), group_identifier_for(name), name] }
end
def group_identifier_for(team_name)
team_name[/G(\d+)/, 1].to_i
end
def seed_identifier_for(team_name)
team_name[/S(\d+)/, 1].to_i
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: "HTTP E2E Group Stage #{group_count}x#{teams_per_group} #{SecureRandom.hex(3)}",
description: 'HTTP lifecycle E2E tournament',
public: false,
group_stage: true,
playoff_teams_amount: playoff_teams_amount,
teams: teams
}
end
def create_playoff_only_payload(team_count:)
{
name: "HTTP E2E Playoff Only #{team_count} #{SecureRandom.hex(3)}",
description: 'HTTP lifecycle E2E tournament',
public: false,
teams: team_count.times.map { |index| { name: "G0-S#{index + 1}" } }
}
end
def summarize_tournament(tournament)
{
id: tournament.fetch(:id),
code: tournament.fetch(:code),
playoff_teams_amount: tournament.fetch(:playoff_teams_amount),
instant_finalists_amount: tournament.fetch(:instant_finalists_amount),
intermediate_round_participants_amount: tournament.fetch(:intermediate_round_participants_amount),
stage_summaries: tournament.fetch(:stages).map do |stage|
{
id: stage.fetch(:id),
level: stage.fetch(:level),
state: stage.fetch(:state),
group_count: stage.fetch(:groups, []).count,
match_states: stage.fetch(:matches, []).map { |match| match.fetch(:state) },
groups: stage.fetch(:groups, []).map do |group|
{
id: group.fetch(:id),
number: group.fetch(:number),
match_states: group.fetch(:matches).map { |match| match.fetch(:state) }
}
end
}
end
}
end
def result(session:, tournament:, checkpoints:, stop_at:)
{
session: session,
stop_at: stop_at.to_s,
tournament: summarize_tournament(tournament),
checkpoints: checkpoints
}
end
def expect_status!(response, expected_status, action)
return if response.fetch(:status) == expected_status
raise ApiError.new(
"#{action} failed with #{response.fetch(:status)}",
status: response.fetch(:status),
body: response.fetch(:json)
)
end
end
end
def login_with_existing_credentials!
login_response = client.login!(email: @email, password: @password)
expect_status!(login_response, 200, "login #{@email}")
raise ApiError.new('login succeeded but no auth headers were returned', status: 200, body: login_response.fetch(:json)) unless client.authenticated?
{ email: @email, username: @username || @email }
end

47
script/e2e_scenarios.rb Normal file
View File

@ -0,0 +1,47 @@
#!/usr/bin/env ruby
# frozen_string_literal: true
require 'json'
require 'optparse'
require_relative '../e2e/lib/scenario_runner'
options = {
base_url: ENV.fetch('TURNIERE_E2E_BASE_URL', 'http://127.0.0.1:3000'),
stop_at: nil,
team_count: 8,
email: ENV['TURNIERE_E2E_EMAIL'],
password: ENV['TURNIERE_E2E_PASSWORD'],
username: ENV['TURNIERE_E2E_USERNAME']
}
OptionParser.new do |parser|
parser.banner = 'Usage: script/e2e_scenarios.rb SCENARIO [options]'
parser.on('--base-url URL', 'API base URL') { |value| options[:base_url] = value }
parser.on('--stop-at CHECKPOINT', 'Stop scenario at a named checkpoint') { |value| options[:stop_at] = value.to_sym }
parser.on('--team-count COUNT', Integer, 'Team count for playoff_only scenario') { |value| options[:team_count] = value }
parser.on('--email EMAIL', 'Confirmed E2E user email') { |value| options[:email] = value }
parser.on('--password PASSWORD', 'Confirmed E2E user password') { |value| options[:password] = value }
parser.on('--username USERNAME', 'Confirmed E2E user username') { |value| options[:username] = value }
end.parse!
scenario = ARGV.shift or abort('Missing scenario name')
runner = TurniereE2E::ScenarioRunner.new(
base_url: options[:base_url],
email: options[:email],
password: options[:password],
username: options[:username]
)
result = case scenario
when 'group_stage_main_usecase'
runner.run_group_stage_main_usecase(stop_at: options[:stop_at] || :final_finish_attempted)
when 'group_stage_intermediate_round'
runner.run_group_stage_intermediate_round(stop_at: options[:stop_at] || :final_finish_attempted)
when 'playoff_only'
runner.run_playoff_only(team_count: options[:team_count], stop_at: options[:stop_at] || :completed)
else
abort("Unknown scenario #{scenario.inspect}")
end
puts JSON.pretty_generate(result)

11
script/wait_for_http.rb Normal file
View File

@ -0,0 +1,11 @@
#!/usr/bin/env ruby
# frozen_string_literal: true
require_relative '../e2e/lib/scenario_runner'
url = ARGV[0] || ENV.fetch('TURNIERE_E2E_BASE_URL', 'http://127.0.0.1:3000')
timeout = (ARGV[1] || 30).to_i
runner = TurniereE2E::ScenarioRunner.new(base_url: url)
runner.wait_for_healthcheck!(timeout_seconds: timeout)
puts "ready: #{url}"

View File

@ -0,0 +1,64 @@
# frozen_string_literal: true
require 'spec_helper'
require_relative '../../../e2e/lib/scenario_runner'
RSpec.describe 'Tournament lifecycle HTTP E2E' do
let(:base_url) { ENV.fetch('TURNIERE_E2E_BASE_URL', 'http://127.0.0.1:3000') }
let(:runner) do
TurniereE2E::ScenarioRunner.new(
base_url: base_url,
email: ENV.fetch('TURNIERE_E2E_EMAIL'),
password: ENV.fetch('TURNIERE_E2E_PASSWORD'),
username: ENV['TURNIERE_E2E_USERNAME']
)
end
it 'drives the main 4x4 group-stage use case over HTTP and preserves the current final-finish behavior' do
result = runner.run_group_stage_main_usecase
expect(result.dig(:checkpoints, :created, :playoff_teams_amount)).to eq(8)
expect(result.dig(:checkpoints, :created, :instant_finalists_amount)).to eq(8)
expect(result.dig(:checkpoints, :created, :intermediate_round_participants_amount)).to eq(0)
expect(result.dig(:checkpoints, :upcoming_group_matches, :match_ids).count).to eq(4)
expect(result.dig(:checkpoints, :statistics, :group_scores_count)).to eq(16)
playoffs_created = result.dig(:checkpoints, :playoffs_created, :stage_summaries)
playoff_entry_stage = playoffs_created.select { |stage| stage[:level] != -1 }.max_by { |stage| stage[:level] }
expect(playoff_entry_stage[:match_states].count).to eq(4)
final_attempt = result.dig(:checkpoints, :final_finish_attempted, :finish_attempt)
expect(final_attempt[:status]).to eq(422)
expect(final_attempt.dig(:body, :error)).to eq('Moving Team one stage down failed')
end
it 'drives the 3-group intermediate-round path over HTTP and preserves the current advancing behavior' do
result = runner.run_group_stage_intermediate_round
created = result.fetch(:checkpoints).fetch(:created)
expect(created[:playoff_teams_amount]).to eq(4)
expect(created[:instant_finalists_amount]).to eq(3)
expect(created[:intermediate_round_participants_amount]).to eq(2)
playoff_entry_stage = result.dig(:checkpoints, :playoffs_created, :stage_summaries)
.select { |stage| stage[:level] != -1 }
.max_by { |stage| stage[:level] }
expect(playoff_entry_stage[:state]).to eq('intermediate_stage')
expect(playoff_entry_stage[:match_states].count('single_team')).to eq(3)
expect(playoff_entry_stage[:match_states].count('not_started')).to eq(1)
final_attempt = result.dig(:checkpoints, :final_finish_attempted, :finish_attempt)
expect(final_attempt[:status]).to eq(422)
expect(final_attempt.dig(:body, :error)).to eq('Moving Team one stage down failed')
end
[2, 3, 5, 8].each do |team_count|
it "drives a #{team_count}-team playoff-only tournament over HTTP to completion" do
result = runner.run_playoff_only(team_count: team_count)
completed_stages = result.dig(:checkpoints, :completed, :stage_summaries)
final_stage = completed_stages.find { |stage| stage[:level] == 0 }
expect(final_stage[:match_states]).to eq(['finished'])
end
end
end

View File

@ -2,9 +2,27 @@
This file records non-obvious backend behaviors that the E2E suite intentionally preserves. This file records non-obvious backend behaviors that the E2E suite intentionally preserves.
## HTTP E2E auth bootstrap
The lifecycle scenarios themselves run only through the HTTP API.
One bootstrap exception currently exists:
- the backend requires email confirmation before a newly registered user can sign in
- there is no public API to complete that confirmation loop without accessing the email channel
- because of that, HTTP E2E runs need a pre-confirmed user
The reusable scenario script supports this through:
- `TURNIERE_E2E_EMAIL`
- `TURNIERE_E2E_PASSWORD`
- optional `TURNIERE_E2E_USERNAME`
Frontend tests can use the same script against a live backend as long as those credentials point to a confirmed account on that backend.
## Group-stage tournaments cannot finish the playoff final through the normal match endpoint ## Group-stage tournaments cannot finish the playoff final through the normal match endpoint
Observed and covered by `spec/requests/e2e/tournament_lifecycle_spec.rb`: Observed and covered by `spec/e2e/http/tournament_lifecycle_spec.rb` and `script/e2e_scenarios.rb`:
- in tournaments that started with a group stage, the playoff final can be started and scored - 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: - but `PATCH /matches/:id` with `state=finished` on that final returns:
@ -16,7 +34,7 @@ This appears to happen because playoff stages count down to level `0`, while the
## Group stage to playoff conversion with 3 groups and `playoff_teams_amount = 4` ## Group stage to playoff conversion with 3 groups and `playoff_teams_amount = 4`
Observed and covered by `spec/requests/e2e/tournament_lifecycle_spec.rb`: Observed and covered by `spec/e2e/http/tournament_lifecycle_spec.rb` and `script/e2e_scenarios.rb`:
- `instant_finalists_amount` becomes `3` - `instant_finalists_amount` becomes `3`
- `intermediate_round_participants_amount` becomes `2` - `intermediate_round_participants_amount` becomes `2`

View File

@ -1,298 +0,0 @@
# 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