Merge branch 'codex/tur-210-playoff-deletion' into 'master'
TUR-210 Playoff deletion See merge request turniere/turniere-backend!81
This commit is contained in:
commit
0bd675ccbd
|
|
@ -13,17 +13,17 @@ class TournamentsController < ApplicationController
|
||||||
|
|
||||||
before_action :set_tournament_for_show, only: %i[show]
|
before_action :set_tournament_for_show, only: %i[show]
|
||||||
before_action :set_tournament,
|
before_action :set_tournament,
|
||||||
only: %i[update destroy set_timer timer sync_state test_sync start_next_group_matches]
|
only: %i[update destroy destroy_playoffs set_timer timer sync_state test_sync start_next_group_matches]
|
||||||
before_action :authenticate_user!, only: %i[create update destroy set_timer test_sync start_next_group_matches]
|
before_action :authenticate_user!, only: %i[create update destroy destroy_playoffs set_timer test_sync start_next_group_matches]
|
||||||
before_action -> { require_owner! @tournament.owner },
|
before_action -> { require_owner! @tournament.owner },
|
||||||
only: %i[update destroy set_timer test_sync start_next_group_matches]
|
only: %i[update destroy destroy_playoffs set_timer test_sync start_next_group_matches]
|
||||||
before_action :validate_create_params, only: %i[create]
|
before_action :validate_create_params, only: %i[create]
|
||||||
before_action :validate_update_params, only: %i[update]
|
before_action :validate_update_params, only: %i[update]
|
||||||
before_action :validate_set_timer_params, only: %i[set_timer]
|
before_action :validate_set_timer_params, only: %i[set_timer]
|
||||||
before_action :ensure_update_allowed!, only: %i[update]
|
before_action :ensure_update_allowed!, only: %i[update]
|
||||||
before_action :ensure_destroy_allowed!, only: %i[destroy]
|
before_action :ensure_destroy_allowed!, only: %i[destroy]
|
||||||
before_action :ensure_timer_update_allowed!, only: %i[set_timer]
|
before_action :ensure_timer_update_allowed!, only: %i[set_timer]
|
||||||
before_action -> { require_writable_tournament!(@tournament) }, only: %i[start_next_group_matches]
|
before_action -> { require_writable_tournament!(@tournament) }, only: %i[destroy_playoffs start_next_group_matches]
|
||||||
before_action :authorize_sync_request!, only: %i[sync_state]
|
before_action :authorize_sync_request!, only: %i[sync_state]
|
||||||
rescue_from ActiveRecord::RecordNotFound, with: :render_not_found_error
|
rescue_from ActiveRecord::RecordNotFound, with: :render_not_found_error
|
||||||
|
|
||||||
|
|
@ -130,6 +130,30 @@ class TournamentsController < ApplicationController
|
||||||
@tournament.destroy
|
@tournament.destroy
|
||||||
end
|
end
|
||||||
|
|
||||||
|
# DELETE /tournaments/:id/playoffs
|
||||||
|
def destroy_playoffs
|
||||||
|
synced_tournament = nil
|
||||||
|
|
||||||
|
Tournament.transaction do
|
||||||
|
TournamentTransactionLogger.lock_tournament!(@tournament)
|
||||||
|
@tournament.reload
|
||||||
|
|
||||||
|
result = TournamentPlayoffDeletion.call(tournament: @tournament)
|
||||||
|
unless result.success?
|
||||||
|
render json: { error: result.error }, status: :unprocessable_content
|
||||||
|
raise ActiveRecord::Rollback
|
||||||
|
end
|
||||||
|
|
||||||
|
synced_tournament = @tournament.reload
|
||||||
|
render json: playoff_deletion_payload(synced_tournament)
|
||||||
|
end
|
||||||
|
|
||||||
|
return if synced_tournament.nil?
|
||||||
|
|
||||||
|
push_sync_if_needed!(synced_tournament)
|
||||||
|
TournamentLiveBroadcast.call(synced_tournament.id)
|
||||||
|
end
|
||||||
|
|
||||||
# GET /tournaments/:id/timer
|
# GET /tournaments/:id/timer
|
||||||
def timer
|
def timer
|
||||||
render json: serialize_timer_state(@tournament)
|
render json: serialize_timer_state(@tournament)
|
||||||
|
|
@ -223,6 +247,19 @@ class TournamentsController < ApplicationController
|
||||||
}
|
}
|
||||||
end
|
end
|
||||||
|
|
||||||
|
def playoff_deletion_payload(tournament)
|
||||||
|
{
|
||||||
|
id: tournament.id,
|
||||||
|
stages: tournament.stages.sort_by(&:level).map do |stage|
|
||||||
|
{
|
||||||
|
id: stage.id,
|
||||||
|
level: stage.level,
|
||||||
|
state: stage.state
|
||||||
|
}
|
||||||
|
end
|
||||||
|
}
|
||||||
|
end
|
||||||
|
|
||||||
def organize_teams_in_groups(teams)
|
def organize_teams_in_groups(teams)
|
||||||
# each team gets put into an array of teams depending on the group specified in team[:group]
|
# each team gets put into an array of teams depending on the group specified in team[:group]
|
||||||
teams.group_by { |team| team['group'] }.values.map do |group|
|
teams.group_by { |team| team['group'] }.values.map do |group|
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,58 @@
|
||||||
|
# frozen_string_literal: true
|
||||||
|
|
||||||
|
class TournamentPlayoffDeletion
|
||||||
|
DELETABLE_MATCH_STATES = %w[not_started not_ready single_team].freeze
|
||||||
|
|
||||||
|
Result = Struct.new(:success, :error, keyword_init: true) do
|
||||||
|
def success?
|
||||||
|
success
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
def self.call(tournament:)
|
||||||
|
new(tournament).call
|
||||||
|
end
|
||||||
|
|
||||||
|
def initialize(tournament)
|
||||||
|
@tournament = tournament
|
||||||
|
end
|
||||||
|
|
||||||
|
def call
|
||||||
|
group_stage = tournament.group_stage
|
||||||
|
return failure('Only group-stage playoffs can be deleted') if group_stage.nil?
|
||||||
|
|
||||||
|
playoff_stages = tournament.stages.reload.select { |stage| stage.level >= 0 }
|
||||||
|
return failure('Playoffs have not been generated') if playoff_stages.empty?
|
||||||
|
unless playoffs_deletable?(playoff_stages)
|
||||||
|
return failure('Playoffs cannot be deleted after matches have started or scores changed')
|
||||||
|
end
|
||||||
|
|
||||||
|
playoff_stages.each(&:destroy!)
|
||||||
|
group_stage.update!(state: :in_progress)
|
||||||
|
success
|
||||||
|
end
|
||||||
|
|
||||||
|
private
|
||||||
|
|
||||||
|
attr_reader :tournament
|
||||||
|
|
||||||
|
def playoffs_deletable?(playoff_stages)
|
||||||
|
playoff_stages.flat_map(&:matches).all? do |match|
|
||||||
|
DELETABLE_MATCH_STATES.include?(match.state) && zero_scores?(match)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
def zero_scores?(match)
|
||||||
|
match.match_scores.all? do |score|
|
||||||
|
score.points.to_i.zero? && score.hidden_points.to_i.zero?
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
def success
|
||||||
|
Result.new(success: true)
|
||||||
|
end
|
||||||
|
|
||||||
|
def failure(error)
|
||||||
|
Result.new(success: false, error: error)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
@ -41,6 +41,7 @@ Rails.application.routes.draw do
|
||||||
patch :sync_state
|
patch :sync_state
|
||||||
patch :start_next_group_matches
|
patch :start_next_group_matches
|
||||||
post :test_sync
|
post :test_sync
|
||||||
|
delete :playoffs, action: :destroy_playoffs
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
resources :match_scores, only: %i[show update]
|
resources :match_scores, only: %i[show update]
|
||||||
|
|
|
||||||
|
|
@ -109,6 +109,43 @@ module TurniereE2E
|
||||||
result(session: session, tournament: tournament, checkpoints: checkpoints, stop_at: stop_at)
|
result(session: session, tournament: tournament, checkpoints: checkpoints, stop_at: stop_at)
|
||||||
end
|
end
|
||||||
|
|
||||||
|
def run_group_stage_playoff_recreation(stop_at: :third_playoffs_created)
|
||||||
|
session = register_session!
|
||||||
|
tournament = create_tournament!(
|
||||||
|
create_group_stage_payload(group_count: 2, teams_per_group: 2, playoff_teams_amount: 2)
|
||||||
|
)
|
||||||
|
|
||||||
|
checkpoints = {
|
||||||
|
created: summarize_tournament(tournament)
|
||||||
|
}
|
||||||
|
rounds = playoff_recreation_rounds
|
||||||
|
created_keys = %i[first_playoffs_created second_playoffs_created third_playoffs_created]
|
||||||
|
deleted_keys = %i[first_playoffs_deleted second_playoffs_deleted]
|
||||||
|
|
||||||
|
rounds.each_with_index do |round, index|
|
||||||
|
tournament = fetch_tournament!(tournament.fetch(:id))
|
||||||
|
apply_group_score_round!(tournament, round.fetch(:scores_by_team_name))
|
||||||
|
finish_stage!(group_stage_of!(fetch_tournament!(tournament.fetch(:id))).fetch(:id))
|
||||||
|
tournament = fetch_tournament!(tournament.fetch(:id))
|
||||||
|
|
||||||
|
created_key = created_keys.fetch(index)
|
||||||
|
checkpoints[created_key] = summarize_tournament(tournament).merge(
|
||||||
|
playoff_matchups: playoff_matchups(tournament)
|
||||||
|
)
|
||||||
|
return result(session: session, tournament: tournament, checkpoints: checkpoints, stop_at: stop_at) if stop_at == created_key
|
||||||
|
|
||||||
|
next if index == rounds.size - 1
|
||||||
|
|
||||||
|
delete_playoffs!(tournament.fetch(:id))
|
||||||
|
tournament = fetch_tournament!(tournament.fetch(:id))
|
||||||
|
deleted_key = deleted_keys.fetch(index)
|
||||||
|
checkpoints[deleted_key] = summarize_tournament(tournament)
|
||||||
|
return result(session: session, tournament: tournament, checkpoints: checkpoints, stop_at: stop_at) if stop_at == deleted_key
|
||||||
|
end
|
||||||
|
|
||||||
|
result(session: session, tournament: tournament, checkpoints: checkpoints, stop_at: stop_at)
|
||||||
|
end
|
||||||
|
|
||||||
def run_playoff_only(team_count:, stop_at: :completed)
|
def run_playoff_only(team_count:, stop_at: :completed)
|
||||||
session = register_session!
|
session = register_session!
|
||||||
tournament = create_tournament!(create_playoff_only_payload(team_count: team_count))
|
tournament = create_tournament!(create_playoff_only_payload(team_count: team_count))
|
||||||
|
|
@ -433,6 +470,12 @@ module TurniereE2E
|
||||||
response.fetch(:json)
|
response.fetch(:json)
|
||||||
end
|
end
|
||||||
|
|
||||||
|
def delete_playoffs!(tournament_id)
|
||||||
|
response = client.delete("/tournaments/#{tournament_id}/playoffs")
|
||||||
|
expect_status!(response, 200, "delete playoffs for tournament #{tournament_id}")
|
||||||
|
response.fetch(:json)
|
||||||
|
end
|
||||||
|
|
||||||
def group_stage_of!(tournament)
|
def group_stage_of!(tournament)
|
||||||
tournament.fetch(:stages).find { |stage| stage.fetch(:level) == -1 } || raise('group stage not found')
|
tournament.fetch(:stages).find { |stage| stage.fetch(:level) == -1 } || raise('group stage not found')
|
||||||
end
|
end
|
||||||
|
|
@ -487,6 +530,34 @@ module TurniereE2E
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
|
def apply_group_score_round!(tournament, scores_by_team_name)
|
||||||
|
group_stage_of!(tournament).fetch(:groups).each do |group|
|
||||||
|
group.fetch(:matches).each do |match|
|
||||||
|
points_by_team_name = match.fetch(:match_scores).to_h do |score|
|
||||||
|
team_name = score.dig(:team, :name)
|
||||||
|
[team_name, scores_by_team_name.fetch(team_name)]
|
||||||
|
end
|
||||||
|
|
||||||
|
if match.fetch(:state) == 'not_started'
|
||||||
|
play_match_with_points!(match, points_by_team_name: points_by_team_name)
|
||||||
|
else
|
||||||
|
update_match_scores!(match, points_by_team_name: points_by_team_name)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
def update_match_scores!(match, points_by_team_name:)
|
||||||
|
refreshed_match = fetch_match!(match.fetch(:id))
|
||||||
|
refreshed_match.fetch(:match_scores).each do |score|
|
||||||
|
team_name = score.dig(:team, :name)
|
||||||
|
updated_score = client.patch("/match_scores/#{score.fetch(:id)}", body: {
|
||||||
|
points: points_by_team_name.fetch(team_name)
|
||||||
|
})
|
||||||
|
expect_status!(updated_score, 200, "update match_score #{score.fetch(:id)}")
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
def preferred_playoff_winner_name(match)
|
def preferred_playoff_winner_name(match)
|
||||||
match.fetch(:match_scores)
|
match.fetch(:match_scores)
|
||||||
.map { |score| score.dig(:team, :name) }
|
.map { |score| score.dig(:team, :name) }
|
||||||
|
|
@ -521,6 +592,35 @@ module TurniereE2E
|
||||||
}
|
}
|
||||||
end
|
end
|
||||||
|
|
||||||
|
def playoff_recreation_rounds
|
||||||
|
[
|
||||||
|
{
|
||||||
|
scores_by_team_name: {
|
||||||
|
'G1-S1' => 3,
|
||||||
|
'G1-S2' => 1,
|
||||||
|
'G2-S1' => 3,
|
||||||
|
'G2-S2' => 1
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
scores_by_team_name: {
|
||||||
|
'G1-S1' => 0,
|
||||||
|
'G1-S2' => 4,
|
||||||
|
'G2-S1' => 0,
|
||||||
|
'G2-S2' => 4
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
scores_by_team_name: {
|
||||||
|
'G1-S1' => 5,
|
||||||
|
'G1-S2' => 2,
|
||||||
|
'G2-S1' => 2,
|
||||||
|
'G2-S2' => 5
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
|
end
|
||||||
|
|
||||||
def create_playoff_only_payload(team_count:)
|
def create_playoff_only_payload(team_count:)
|
||||||
{
|
{
|
||||||
name: "HTTP E2E Playoff Only #{team_count} #{SecureRandom.hex(3)}",
|
name: "HTTP E2E Playoff Only #{team_count} #{SecureRandom.hex(3)}",
|
||||||
|
|
@ -611,6 +711,19 @@ module TurniereE2E
|
||||||
}
|
}
|
||||||
end
|
end
|
||||||
|
|
||||||
|
def playoff_matchups(tournament)
|
||||||
|
entry_stage = tournament.fetch(:stages)
|
||||||
|
.reject { |stage| stage.fetch(:level) == -1 }
|
||||||
|
.max_by { |stage| stage.fetch(:level) }
|
||||||
|
entry_stage.fetch(:matches)
|
||||||
|
.sort_by { |match| match.fetch(:position) }
|
||||||
|
.map do |match|
|
||||||
|
match.fetch(:match_scores)
|
||||||
|
.map { |score| score.dig(:team, :name) }
|
||||||
|
.sort
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
def fetch_tournament_profile!(tournament_id)
|
def fetch_tournament_profile!(tournament_id)
|
||||||
started_at = Process.clock_gettime(Process::CLOCK_MONOTONIC)
|
started_at = Process.clock_gettime(Process::CLOCK_MONOTONIC)
|
||||||
response = get_response!("/tournaments/#{tournament_id}", params: { profile: 'true' })
|
response = get_response!("/tournaments/#{tournament_id}", params: { profile: 'true' })
|
||||||
|
|
|
||||||
|
|
@ -46,6 +46,8 @@ result = case scenario
|
||||||
runner.run_group_stage_main_usecase(stop_at: options[:stop_at] || :final_finish_attempted)
|
runner.run_group_stage_main_usecase(stop_at: options[:stop_at] || :final_finish_attempted)
|
||||||
when 'group_stage_intermediate_round'
|
when 'group_stage_intermediate_round'
|
||||||
runner.run_group_stage_intermediate_round(stop_at: options[:stop_at] || :final_finish_attempted)
|
runner.run_group_stage_intermediate_round(stop_at: options[:stop_at] || :final_finish_attempted)
|
||||||
|
when 'group_stage_playoff_recreation'
|
||||||
|
runner.run_group_stage_playoff_recreation(stop_at: options[:stop_at] || :third_playoffs_created)
|
||||||
when 'playoff_only'
|
when 'playoff_only'
|
||||||
runner.run_playoff_only(team_count: options[:team_count], stop_at: options[:stop_at] || :completed)
|
runner.run_playoff_only(team_count: options[:team_count], stop_at: options[:stop_at] || :completed)
|
||||||
when 'group_stage_render_profile'
|
when 'group_stage_render_profile'
|
||||||
|
|
|
||||||
|
|
@ -898,6 +898,119 @@ RSpec.describe TournamentsController, type: :controller do
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
|
describe 'DELETE #destroy_playoffs' do
|
||||||
|
context 'without authentication headers' do
|
||||||
|
it 'renders an unauthorized error response' do
|
||||||
|
tournament = create_group_stage_with_unplayed_playoffs
|
||||||
|
|
||||||
|
delete :destroy_playoffs, params: { id: tournament.to_param }
|
||||||
|
|
||||||
|
expect(response).to have_http_status(:unauthorized)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
context 'as owner' do
|
||||||
|
before do
|
||||||
|
apply_authentication_headers_for tournament.owner
|
||||||
|
allow(TournamentLiveBroadcast).to receive(:call)
|
||||||
|
end
|
||||||
|
|
||||||
|
let(:tournament) { create_group_stage_with_unplayed_playoffs }
|
||||||
|
|
||||||
|
it 'deletes unplayed playoff stages and reopens the group stage' do
|
||||||
|
expect do
|
||||||
|
delete :destroy_playoffs, params: { id: tournament.to_param }
|
||||||
|
end.to change { tournament.reload.stages.where('level >= 0').count }.from(1).to(0)
|
||||||
|
|
||||||
|
expect(response).to have_http_status(:ok)
|
||||||
|
expect(tournament.group_stage.reload).to be_in_progress
|
||||||
|
expect(deserialize_response(response).fetch(:stages).map { |stage| stage.fetch(:level) }).to eq([-1])
|
||||||
|
end
|
||||||
|
|
||||||
|
it 'pushes sync and live updates after deletion' do
|
||||||
|
expect(TournamentSyncEnqueue).to receive(:call).with(tournament)
|
||||||
|
expect(TournamentLiveBroadcast).to receive(:call).with(tournament.id)
|
||||||
|
|
||||||
|
delete :destroy_playoffs, params: { id: tournament.to_param }
|
||||||
|
|
||||||
|
expect(response).to have_http_status(:ok)
|
||||||
|
end
|
||||||
|
|
||||||
|
it 'rejects deletion after a playoff match has started' do
|
||||||
|
playoff_match = tournament.stages.find { |stage| stage.level >= 0 }.matches.first
|
||||||
|
playoff_match.update!(state: :in_progress)
|
||||||
|
|
||||||
|
expect do
|
||||||
|
delete :destroy_playoffs, params: { id: tournament.to_param }
|
||||||
|
end.not_to(change { tournament.reload.stages.where('level >= 0').count })
|
||||||
|
|
||||||
|
expect(response).to have_http_status(:unprocessable_content)
|
||||||
|
expect(deserialize_response(response)[:error]).to eq(
|
||||||
|
'Playoffs cannot be deleted after matches have started or scores changed'
|
||||||
|
)
|
||||||
|
expect(tournament.group_stage.reload).to be_finished
|
||||||
|
end
|
||||||
|
|
||||||
|
it 'rejects deletion after a playoff score has changed' do
|
||||||
|
playoff_match = tournament.stages.find { |stage| stage.level >= 0 }.matches.first
|
||||||
|
playoff_match.match_scores.first.update!(points: 1)
|
||||||
|
|
||||||
|
delete :destroy_playoffs, params: { id: tournament.to_param }
|
||||||
|
|
||||||
|
expect(response).to have_http_status(:unprocessable_content)
|
||||||
|
expect(deserialize_response(response)[:error]).to eq(
|
||||||
|
'Playoffs cannot be deleted after matches have started or scores changed'
|
||||||
|
)
|
||||||
|
expect(tournament.reload.stages.where('level >= 0').count).to eq(1)
|
||||||
|
end
|
||||||
|
|
||||||
|
it 'rejects deletion when no group stage exists' do
|
||||||
|
playoff_only = create(:stage_tournament, stage_count: 1)
|
||||||
|
apply_authentication_headers_for playoff_only.owner
|
||||||
|
|
||||||
|
delete :destroy_playoffs, params: { id: playoff_only.to_param }
|
||||||
|
|
||||||
|
expect(response).to have_http_status(:unprocessable_content)
|
||||||
|
expect(deserialize_response(response)[:error]).to eq('Only group-stage playoffs can be deleted')
|
||||||
|
end
|
||||||
|
|
||||||
|
it 'blocks deletion while tournament is read only' do
|
||||||
|
tournament.update!(read_only_mode: true, sync_auth_token: 'shared-secret')
|
||||||
|
|
||||||
|
delete :destroy_playoffs, params: { id: tournament.to_param }
|
||||||
|
|
||||||
|
expect(response).to have_http_status(:locked)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
context 'as another user' do
|
||||||
|
it 'renders a forbidden error response' do
|
||||||
|
tournament = create_group_stage_with_unplayed_playoffs
|
||||||
|
apply_authentication_headers_for create(:user)
|
||||||
|
|
||||||
|
delete :destroy_playoffs, params: { id: tournament.to_param }
|
||||||
|
|
||||||
|
expect(response).to have_http_status(:forbidden)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
def create_group_stage_with_unplayed_playoffs
|
||||||
|
tournament = create(
|
||||||
|
:group_stage_tournament,
|
||||||
|
stage_count: 0,
|
||||||
|
group_count: 2,
|
||||||
|
teams_count: 4,
|
||||||
|
playoff_teams_amount: 2,
|
||||||
|
instant_finalists_amount: 2,
|
||||||
|
intermediate_round_participants_amount: 0
|
||||||
|
)
|
||||||
|
tournament.group_stage.update!(state: :finished)
|
||||||
|
result = AddPlayoffsToTournamentAndSave.call(tournament: tournament, teams: tournament.teams.take(2))
|
||||||
|
expect(result).to be_a_success
|
||||||
|
tournament.reload
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
describe 'PATCH #sync_state' do
|
describe 'PATCH #sync_state' do
|
||||||
before do
|
before do
|
||||||
@tournament.update!(read_only_mode: true, sync_auth_token: 'shared-secret')
|
@tournament.update!(read_only_mode: true, sync_auth_token: 'shared-secret')
|
||||||
|
|
|
||||||
|
|
@ -277,6 +277,64 @@ RSpec.describe 'Tournament follower sync HTTP E2E' do
|
||||||
expect(takeover_update[:status]).to eq(200)
|
expect(takeover_update[:status]).to eq(200)
|
||||||
end
|
end
|
||||||
|
|
||||||
|
it 'mirrors repeated playoff deletion and recreation into the follower tournament' do
|
||||||
|
log_phase!('create small source and follower tournaments')
|
||||||
|
source = create_group_stage_tournament(
|
||||||
|
client: source_owner_client,
|
||||||
|
public: false,
|
||||||
|
name_prefix: 'Sync Playoff Delete Source',
|
||||||
|
group_count: 2,
|
||||||
|
teams_per_group: 2,
|
||||||
|
playoff_teams_amount: 2
|
||||||
|
)
|
||||||
|
follower = create_follower_tournament(client: follower_owner_client, public: true, name_prefix: 'Sync Playoff Delete Follower')
|
||||||
|
|
||||||
|
configure_sync = source_owner_client.patch("/tournaments/#{source.fetch(:id)}", body: {
|
||||||
|
sync_target_url: sync_state_url(follower.fetch(:id)),
|
||||||
|
sync_auth_token: sync_token
|
||||||
|
})
|
||||||
|
expect(configure_sync[:status]).to eq(200)
|
||||||
|
follower = wait_for_tournament_sync!(source_tournament_id: source.fetch(:id), follower_tournament_id: follower.fetch(:id))
|
||||||
|
expect(tournament_signature(follower)).to eq(tournament_signature(source))
|
||||||
|
|
||||||
|
rounds = playoff_recreation_rounds
|
||||||
|
rounds.each_with_index do |round, index|
|
||||||
|
log_phase!("create playoffs round #{index + 1}")
|
||||||
|
apply_group_score_round!(
|
||||||
|
source_tournament_id: source.fetch(:id),
|
||||||
|
scores_by_team_name: round.fetch(:scores_by_team_name)
|
||||||
|
)
|
||||||
|
source = fetch_tournament(client: source_anonymous_client, tournament_id: source.fetch(:id))
|
||||||
|
group_stage = source.fetch(:stages).find { |stage| stage.fetch(:level) == -1 }
|
||||||
|
finish_stage = source_owner_client.patch("/stages/#{group_stage.fetch(:id)}", body: { state: 'finished' })
|
||||||
|
expect(finish_stage[:status]).to eq(200)
|
||||||
|
|
||||||
|
source = fetch_tournament(client: source_anonymous_client, tournament_id: source.fetch(:id))
|
||||||
|
follower = wait_for_tournament_sync!(source_tournament_id: source.fetch(:id), follower_tournament_id: follower.fetch(:id))
|
||||||
|
expect(playoff_matchups(source)).to eq(round.fetch(:expected_playoff_matchups))
|
||||||
|
expect(playoff_matchups(follower)).to eq(round.fetch(:expected_playoff_matchups))
|
||||||
|
expect(tournament_signature(follower)).to eq(tournament_signature(source))
|
||||||
|
|
||||||
|
next if index == rounds.size - 1
|
||||||
|
|
||||||
|
locked_delete = follower_owner_client.delete("/tournaments/#{follower.fetch(:id)}/playoffs")
|
||||||
|
expect(locked_delete[:status]).to eq(423)
|
||||||
|
expect(locked_delete.dig(:json, :error)).to eq('Tournament is in read only follower mode')
|
||||||
|
|
||||||
|
log_phase!("delete playoffs round #{index + 1}")
|
||||||
|
delete_playoffs = source_owner_client.delete("/tournaments/#{source.fetch(:id)}/playoffs")
|
||||||
|
expect(delete_playoffs[:status]).to eq(200)
|
||||||
|
|
||||||
|
source = fetch_tournament(client: source_anonymous_client, tournament_id: source.fetch(:id))
|
||||||
|
follower = wait_for_tournament_sync!(source_tournament_id: source.fetch(:id), follower_tournament_id: follower.fetch(:id))
|
||||||
|
expect(source.fetch(:stages).map { |stage| stage.fetch(:level) }).to eq([-1])
|
||||||
|
expect(follower.fetch(:stages).map { |stage| stage.fetch(:level) }).to eq([-1])
|
||||||
|
expect(source.fetch(:stages).first.fetch(:state)).to eq('in_progress')
|
||||||
|
expect(follower.fetch(:stages).first.fetch(:state)).to eq('in_progress')
|
||||||
|
expect(tournament_signature(follower)).to eq(tournament_signature(source))
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
def log_phase!(message)
|
def log_phase!(message)
|
||||||
puts "phase #{message}"
|
puts "phase #{message}"
|
||||||
$stdout.flush
|
$stdout.flush
|
||||||
|
|
@ -291,7 +349,10 @@ RSpec.describe 'Tournament follower sync HTTP E2E' do
|
||||||
client
|
client
|
||||||
end
|
end
|
||||||
|
|
||||||
def create_group_stage_tournament(client:, public:, name_prefix:)
|
def create_group_stage_tournament(client:, public:, name_prefix:, **options)
|
||||||
|
group_count = options.fetch(:group_count, 4)
|
||||||
|
teams_per_group = options.fetch(:teams_per_group, 4)
|
||||||
|
playoff_teams_amount = options.fetch(:playoff_teams_amount, 8)
|
||||||
payload = {
|
payload = {
|
||||||
name: "#{name_prefix} #{unique_suffix}",
|
name: "#{name_prefix} #{unique_suffix}",
|
||||||
description: 'Follower sync source tournament',
|
description: 'Follower sync source tournament',
|
||||||
|
|
@ -301,9 +362,9 @@ RSpec.describe 'Tournament follower sync HTTP E2E' do
|
||||||
ends_at: '2030-06-01T18:00:00+02:00',
|
ends_at: '2030-06-01T18:00:00+02:00',
|
||||||
website_url: 'https://example.com/sync-source',
|
website_url: 'https://example.com/sync-source',
|
||||||
group_stage: true,
|
group_stage: true,
|
||||||
playoff_teams_amount: 8,
|
playoff_teams_amount: playoff_teams_amount,
|
||||||
teams: 4.times.flat_map do |group_index|
|
teams: group_count.times.flat_map do |group_index|
|
||||||
4.times.map do |seed_index|
|
teams_per_group.times.map do |seed_index|
|
||||||
{
|
{
|
||||||
name: "#{name_prefix.tr(' ', '')}-G#{group_index + 1}-S#{seed_index + 1}",
|
name: "#{name_prefix.tr(' ', '')}-G#{group_index + 1}-S#{seed_index + 1}",
|
||||||
group: group_index
|
group: group_index
|
||||||
|
|
@ -317,6 +378,10 @@ RSpec.describe 'Tournament follower sync HTTP E2E' do
|
||||||
fetch_tournament(client: client, tournament_id: response.dig(:json, :id))
|
fetch_tournament(client: client, tournament_id: response.dig(:json, :id))
|
||||||
end
|
end
|
||||||
|
|
||||||
|
def sync_state_url(follower_id)
|
||||||
|
"#{follower_base_url}/tournaments/#{follower_id}/sync_state"
|
||||||
|
end
|
||||||
|
|
||||||
def create_playoff_tournament(client:, public:, name_prefix:)
|
def create_playoff_tournament(client:, public:, name_prefix:)
|
||||||
payload = {
|
payload = {
|
||||||
name: "#{name_prefix} #{unique_suffix}",
|
name: "#{name_prefix} #{unique_suffix}",
|
||||||
|
|
@ -535,6 +600,97 @@ RSpec.describe 'Tournament follower sync HTTP E2E' do
|
||||||
play_match!(match_id: match.fetch(:id), first_score: score_a, second_score: score_b)
|
play_match!(match_id: match.fetch(:id), first_score: score_a, second_score: score_b)
|
||||||
end
|
end
|
||||||
|
|
||||||
|
def playoff_recreation_rounds
|
||||||
|
[
|
||||||
|
{
|
||||||
|
scores_by_team_name: {
|
||||||
|
'G1-S1' => 3,
|
||||||
|
'G1-S2' => 1,
|
||||||
|
'G2-S1' => 3,
|
||||||
|
'G2-S2' => 1
|
||||||
|
},
|
||||||
|
expected_playoff_matchups: [
|
||||||
|
%w[G1-S1 G2-S2],
|
||||||
|
%w[G1-S2 G2-S1]
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
scores_by_team_name: {
|
||||||
|
'G1-S1' => 0,
|
||||||
|
'G1-S2' => 4,
|
||||||
|
'G2-S1' => 0,
|
||||||
|
'G2-S2' => 4
|
||||||
|
},
|
||||||
|
expected_playoff_matchups: [
|
||||||
|
%w[G1-S2 G2-S1],
|
||||||
|
%w[G1-S1 G2-S2]
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
scores_by_team_name: {
|
||||||
|
'G1-S1' => 5,
|
||||||
|
'G1-S2' => 2,
|
||||||
|
'G2-S1' => 2,
|
||||||
|
'G2-S2' => 5
|
||||||
|
},
|
||||||
|
expected_playoff_matchups: [
|
||||||
|
%w[G1-S1 G2-S1],
|
||||||
|
%w[G1-S2 G2-S2]
|
||||||
|
]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
end
|
||||||
|
|
||||||
|
def apply_group_score_round!(source_tournament_id:, scores_by_team_name:)
|
||||||
|
tournament = fetch_tournament(client: source_anonymous_client, tournament_id: source_tournament_id)
|
||||||
|
group_stage = tournament.fetch(:stages).find { |stage| stage.fetch(:level) == -1 }
|
||||||
|
|
||||||
|
group_stage.fetch(:groups).each do |group|
|
||||||
|
group.fetch(:matches).each do |match|
|
||||||
|
if match.fetch(:state) == 'not_started'
|
||||||
|
start_response = source_owner_client.patch("/matches/#{match.fetch(:id)}", body: { state: 'in_progress' })
|
||||||
|
expect(start_response[:status]).to eq(200)
|
||||||
|
end
|
||||||
|
|
||||||
|
update_match_points_by_seed!(match.fetch(:id), scores_by_team_name)
|
||||||
|
|
||||||
|
next if match.fetch(:state) == 'finished'
|
||||||
|
|
||||||
|
finish_response = source_owner_client.patch("/matches/#{match.fetch(:id)}", body: { state: 'finished' })
|
||||||
|
expect(finish_response[:status]).to eq(200)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
def update_match_points_by_seed!(match_id, scores_by_team_name)
|
||||||
|
match = source_anonymous_client.get("/matches/#{match_id}")
|
||||||
|
expect(match[:status]).to eq(200)
|
||||||
|
|
||||||
|
match.fetch(:json).fetch(:match_scores).each do |score|
|
||||||
|
response = source_owner_client.patch("/match_scores/#{score.fetch(:id)}", body: {
|
||||||
|
points: scores_by_team_name.fetch(seed_label(score.dig(:team, :name)))
|
||||||
|
})
|
||||||
|
expect(response[:status]).to eq(200)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
def playoff_matchups(tournament)
|
||||||
|
entry_stage = tournament.fetch(:stages)
|
||||||
|
.select { |stage| stage.fetch(:level) >= 0 }
|
||||||
|
.max_by { |stage| stage.fetch(:level) }
|
||||||
|
entry_stage.fetch(:matches)
|
||||||
|
.sort_by { |match| match.fetch(:position) }
|
||||||
|
.map do |match|
|
||||||
|
match.fetch(:match_scores)
|
||||||
|
.map { |score| seed_label(score.dig(:team, :name)) }
|
||||||
|
.sort
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
def seed_label(team_name)
|
||||||
|
team_name[/G\d+-S\d+\z/] || team_name
|
||||||
|
end
|
||||||
|
|
||||||
def finish_playoff_bracket!(source_id:)
|
def finish_playoff_bracket!(source_id:)
|
||||||
loop do
|
loop do
|
||||||
tournament = fetch_tournament(client: source_anonymous_client, tournament_id: source_id)
|
tournament = fetch_tournament(client: source_anonymous_client, tournament_id: source_id)
|
||||||
|
|
|
||||||
|
|
@ -61,6 +61,27 @@ RSpec.describe 'Tournament lifecycle HTTP E2E' do
|
||||||
.find { |stage| stage[:level] == 0 }[:match_states]).to eq(%w[finished finished])
|
.find { |stage| stage[:level] == 0 }[:match_states]).to eq(%w[finished finished])
|
||||||
end
|
end
|
||||||
|
|
||||||
|
it 'deletes and recreates group-stage playoffs over HTTP with changed group results' do
|
||||||
|
result = runner.run_group_stage_playoff_recreation
|
||||||
|
checkpoints = result.fetch(:checkpoints)
|
||||||
|
|
||||||
|
expect(checkpoints.fetch(:first_playoffs_created).fetch(:playoff_matchups)).to eq([
|
||||||
|
%w[G1-S1 G2-S2],
|
||||||
|
%w[G1-S2 G2-S1]
|
||||||
|
])
|
||||||
|
expect(checkpoints.fetch(:first_playoffs_deleted).fetch(:stage_summaries).map { |stage| stage.fetch(:level) }).to eq([-1])
|
||||||
|
expect(checkpoints.fetch(:first_playoffs_deleted).fetch(:stage_summaries).first.fetch(:state)).to eq('in_progress')
|
||||||
|
expect(checkpoints.fetch(:second_playoffs_created).fetch(:playoff_matchups)).to eq([
|
||||||
|
%w[G1-S2 G2-S1],
|
||||||
|
%w[G1-S1 G2-S2]
|
||||||
|
])
|
||||||
|
expect(checkpoints.fetch(:second_playoffs_deleted).fetch(:stage_summaries).map { |stage| stage.fetch(:level) }).to eq([-1])
|
||||||
|
expect(checkpoints.fetch(:third_playoffs_created).fetch(:playoff_matchups)).to eq([
|
||||||
|
%w[G1-S1 G2-S1],
|
||||||
|
%w[G1-S2 G2-S2]
|
||||||
|
])
|
||||||
|
end
|
||||||
|
|
||||||
{
|
{
|
||||||
won: [1, 2],
|
won: [1, 2],
|
||||||
lost: [2, 1],
|
lost: [2, 1],
|
||||||
|
|
|
||||||
|
|
@ -28,6 +28,10 @@ RSpec.describe TournamentsController, type: :routing do
|
||||||
expect(delete: '/tournaments/1').to route_to('tournaments#destroy', id: '1')
|
expect(delete: '/tournaments/1').to route_to('tournaments#destroy', id: '1')
|
||||||
end
|
end
|
||||||
|
|
||||||
|
it 'routes to #destroy_playoffs' do
|
||||||
|
expect(delete: '/tournaments/1/playoffs').to route_to('tournaments#destroy_playoffs', id: '1')
|
||||||
|
end
|
||||||
|
|
||||||
it 'routes to #sync_state' do
|
it 'routes to #sync_state' do
|
||||||
expect(patch: '/tournaments/1/sync_state').to route_to('tournaments#sync_state', id: '1')
|
expect(patch: '/tournaments/1/sync_state').to route_to('tournaments#sync_state', id: '1')
|
||||||
end
|
end
|
||||||
|
|
|
||||||
22
tasks.py
22
tasks.py
|
|
@ -1049,6 +1049,28 @@ def scenario_intermediate(
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@task(name="scenario-playoff-recreation")
|
||||||
|
def scenario_playoff_recreation(
|
||||||
|
c,
|
||||||
|
base_url=TEST_BASE_URL,
|
||||||
|
email=E2E_EMAIL,
|
||||||
|
password=E2E_PASSWORD,
|
||||||
|
username=E2E_USERNAME,
|
||||||
|
stop_at="third_playoffs_created",
|
||||||
|
):
|
||||||
|
"""Materialize a group-stage playoff delete/recreate scenario over HTTP."""
|
||||||
|
c.run(
|
||||||
|
f"ruby script/e2e_scenarios.rb group_stage_playoff_recreation --stop-at {stop_at}",
|
||||||
|
env=_env(
|
||||||
|
TURNIERE_E2E_BASE_URL=base_url,
|
||||||
|
TURNIERE_E2E_EMAIL=email,
|
||||||
|
TURNIERE_E2E_PASSWORD=password,
|
||||||
|
TURNIERE_E2E_USERNAME=username,
|
||||||
|
),
|
||||||
|
pty=True,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
@task(name="scenario-playoff")
|
@task(name="scenario-playoff")
|
||||||
def scenario_playoff(
|
def scenario_playoff(
|
||||||
c,
|
c,
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue