turniere-backend/e2e/lib/scenario_runner.rb

577 lines
22 KiB
Ruby

# frozen_string_literal: true
require_relative 'api_client'
require 'securerandom'
require 'time'
require 'uri'
module TurniereE2E
class ScenarioRunner
DEFAULT_PASSWORD = 'password123'.freeze
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)}/timer",
body: { timestamp_seconds: 600, timer_reason: 'tournament_start' }
)
checkpoints[:timer_set] = {
timestamp: timer_response.fetch(:timestamp),
timer_reason: timer_response.fetch(:timer_reason)
}
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
finish_attempt = finish_final_stage_matches!(tournament)
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
finish_attempt = finish_final_stage_matches!(tournament)
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 run_group_direct_comparison_tiebreak(case_name:, stop_at: :group_scores)
session = register_session!
tournament = create_tournament!(
create_group_stage_payload(group_count: 1, teams_per_group: 4, playoff_teams_amount: 2)
)
checkpoints = {
created: summarize_tournament(tournament)
}
return result(session: session, tournament: tournament, checkpoints: checkpoints, stop_at: stop_at) if stop_at == :created
group = group_stage_of!(tournament).fetch(:groups).first
play_group_matches_with_explicit_scores!(group, group_direct_comparison_score_plan(case_name))
group = get!("/groups/#{group.fetch(:id)}")
checkpoints[:group_scores] = {
ordered_group_scores: ordered_group_scores(group),
team_positions: team_positions(group)
}
tournament = fetch_tournament!(tournament.fetch(:id))
result(session: session, tournament: tournament, checkpoints: checkpoints, stop_at: stop_at)
end
def run_group_stage_render_profile(group_count:, teams_per_group:, playoff_teams_amount:, stop_at: :profiled_show)
session = register_session!
tournament = create_tournament!(
create_group_stage_payload(
group_count: group_count,
teams_per_group: teams_per_group,
playoff_teams_amount: playoff_teams_amount
)
)
checkpoints = {
created: summarize_tournament(tournament)
}
return result(session: session, tournament: tournament, checkpoints: checkpoints, stop_at: stop_at) if stop_at == :created
profiled_show = fetch_tournament_profile!(tournament.fetch(:id))
checkpoints[:profiled_show] = profiled_show
result(session: session, tournament: profiled_show.fetch(: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 login_with_existing_credentials!
login_response = client.login!(email: @email, password: @password)
expect_status!(login_response, 200, "login #{@email}")
unless client.authenticated?
raise ApiError.new(
'login succeeded but no auth headers were returned',
status: 200,
body: login_response.fetch(:json)
)
end
{ email: @email, username: @username || @email }
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 = get_response!("/tournaments/#{tournament_id}")
expect_status!(response, 200, "fetch tournament #{tournament_id}")
response.fetch(:json)
end
def get!(path, params: nil)
response = get_response!(path, params: params)
expect_status!(response, 200, "get #{path}")
response.fetch(:json)
end
def get_response!(path, params: nil)
response = client.get(path, params: params)
expect_status!(response, 200, "get #{path}")
response
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 play_match_with_points!(match, points_by_team_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).each do |score|
team_name = score.dig(:team, :name)
points = points_by_team_name.fetch(team_name) do
raise "missing score for #{team_name.inspect} in match #{match.fetch(:id)}"
end
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_stage_matches_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).sort_by { |match| match.fetch(:position) }
end
def final_match_of!(tournament)
final_stage_matches_of!(tournament).first
end
def finish_final_stage_matches!(tournament)
final_stage_matches_of!(tournament).each do |match|
play_match!(match, winner_name: preferred_playoff_winner_name(match))
end
{
status: 200,
finished_match_ids: final_stage_matches_of!(fetch_tournament!(tournament.fetch(:id))).map { |match| match.fetch(:id) }
}
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 play_group_matches_with_explicit_scores!(group, score_plan)
matches_by_teams = group.fetch(:matches).each_with_object({}) do |match, memo|
team_names = match.fetch(:match_scores).map { |score| score.dig(:team, :name) }.sort
memo[team_names] = match
end
score_plan.each do |team_names, points_by_team_name|
match = matches_by_teams.fetch(team_names.sort) do
raise "match for #{team_names.inspect} not found"
end
play_match_with_points!(match, points_by_team_name: points_by_team_name)
end
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 ordered_group_scores(group)
group.fetch(:group_scores)
.sort_by { |score| [score.fetch(:position), score.dig(:team, :name)] }
.map do |score|
{
team_name: score.dig(:team, :name),
position: score.fetch(:position),
group_points: score.fetch(:group_points),
difference_in_points: score.fetch(:difference_in_points),
scored_points: score.fetch(:scored_points),
received_points: score.fetch(:received_points)
}
end
end
def team_positions(group)
group.fetch(:group_scores).each_with_object({}) do |score, memo|
memo[score.dig(:team, :name)] = score.fetch(:position)
end
end
def group_direct_comparison_score_plan(case_name)
case case_name.to_sym
when :won
{
%w[G1-S1 G1-S2] => { 'G1-S1' => 2, 'G1-S2' => 1 },
%w[G1-S1 G1-S3] => { 'G1-S1' => 3, 'G1-S3' => 0 },
%w[G1-S1 G1-S4] => { 'G1-S1' => 0, 'G1-S4' => 1 },
%w[G1-S2 G1-S3] => { 'G1-S2' => 2, 'G1-S3' => 0 },
%w[G1-S2 G1-S4] => { 'G1-S2' => 2, 'G1-S4' => 0 },
%w[G1-S3 G1-S4] => { 'G1-S3' => 1, 'G1-S4' => 0 }
}
when :lost
{
%w[G1-S1 G1-S2] => { 'G1-S1' => 1, 'G1-S2' => 2 },
%w[G1-S1 G1-S3] => { 'G1-S1' => 2, 'G1-S3' => 0 },
%w[G1-S1 G1-S4] => { 'G1-S1' => 2, 'G1-S4' => 0 },
%w[G1-S2 G1-S3] => { 'G1-S2' => 1, 'G1-S3' => 0 },
%w[G1-S2 G1-S4] => { 'G1-S2' => 2, 'G1-S4' => 1 },
%w[G1-S3 G1-S4] => { 'G1-S3' => 1, 'G1-S4' => 0 }
}
when :drawn
{
%w[G1-S1 G1-S2] => { 'G1-S1' => 1, 'G1-S2' => 1 },
%w[G1-S1 G1-S3] => { 'G1-S1' => 2, 'G1-S3' => 0 },
%w[G1-S1 G1-S4] => { 'G1-S1' => 1, 'G1-S4' => 0 },
%w[G1-S2 G1-S3] => { 'G1-S2' => 2, 'G1-S3' => 0 },
%w[G1-S2 G1-S4] => { 'G1-S2' => 1, 'G1-S4' => 0 },
%w[G1-S3 G1-S4] => { 'G1-S3' => 1, 'G1-S4' => 0 }
}
else
raise "unknown direct comparison case #{case_name.inspect}"
end
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 fetch_tournament_profile!(tournament_id)
started_at = Process.clock_gettime(Process::CLOCK_MONOTONIC)
response = get_response!("/tournaments/#{tournament_id}", params: { profile: 'true' })
duration_ms = (Process.clock_gettime(Process::CLOCK_MONOTONIC) - started_at) * 1000.0
{
tournament: response.fetch(:json),
request_duration_ms: duration_ms.round(1),
server_timing: response.fetch(:headers).fetch('server-timing', ''),
profile_summary: response.fetch(:headers).fetch('x-turniere-profile', ''),
sections: parse_server_timing(response.fetch(:headers).fetch('server-timing', ''))
}
end
def parse_server_timing(header)
return {} if header.nil? || header.empty?
header.split(',').each_with_object({}) do |entry, sections|
name, duration = entry.strip.match(/\A([^;]+);dur=([0-9.]+)\z/)&.captures
next if name.nil? || duration.nil?
sections[name] = duration.to_f
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