Run backend E2E over HTTP
This commit is contained in:
parent
71be61791a
commit
39ba1cff22
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
@ -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
|
||||||
|
|
@ -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)
|
||||||
|
|
@ -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}"
|
||||||
|
|
@ -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
|
||||||
|
|
@ -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`
|
||||||
|
|
|
||||||
|
|
@ -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
|
|
||||||
Loading…
Reference in New Issue