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:
Daniel Schädler 2026-05-04 17:07:28 +00:00
commit 0bd675ccbd
10 changed files with 535 additions and 8 deletions

View File

@ -13,17 +13,17 @@ class TournamentsController < ApplicationController
before_action :set_tournament_for_show, only: %i[show]
before_action :set_tournament,
only: %i[update destroy 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]
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 destroy_playoffs set_timer test_sync start_next_group_matches]
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_update_params, only: %i[update]
before_action :validate_set_timer_params, only: %i[set_timer]
before_action :ensure_update_allowed!, only: %i[update]
before_action :ensure_destroy_allowed!, only: %i[destroy]
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]
rescue_from ActiveRecord::RecordNotFound, with: :render_not_found_error
@ -130,6 +130,30 @@ class TournamentsController < ApplicationController
@tournament.destroy
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
def timer
render json: serialize_timer_state(@tournament)
@ -223,6 +247,19 @@ class TournamentsController < ApplicationController
}
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)
# 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|

View File

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

View File

@ -41,6 +41,7 @@ Rails.application.routes.draw do
patch :sync_state
patch :start_next_group_matches
post :test_sync
delete :playoffs, action: :destroy_playoffs
end
end
resources :match_scores, only: %i[show update]

View File

@ -109,6 +109,43 @@ module TurniereE2E
result(session: session, tournament: tournament, checkpoints: checkpoints, stop_at: stop_at)
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)
session = register_session!
tournament = create_tournament!(create_playoff_only_payload(team_count: team_count))
@ -433,6 +470,12 @@ module TurniereE2E
response.fetch(:json)
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)
tournament.fetch(:stages).find { |stage| stage.fetch(:level) == -1 } || raise('group stage not found')
end
@ -487,6 +530,34 @@ module TurniereE2E
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)
match.fetch(:match_scores)
.map { |score| score.dig(:team, :name) }
@ -521,6 +592,35 @@ module TurniereE2E
}
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:)
{
name: "HTTP E2E Playoff Only #{team_count} #{SecureRandom.hex(3)}",
@ -611,6 +711,19 @@ module TurniereE2E
}
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)
started_at = Process.clock_gettime(Process::CLOCK_MONOTONIC)
response = get_response!("/tournaments/#{tournament_id}", params: { profile: 'true' })

View File

@ -46,6 +46,8 @@ result = case scenario
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 'group_stage_playoff_recreation'
runner.run_group_stage_playoff_recreation(stop_at: options[:stop_at] || :third_playoffs_created)
when 'playoff_only'
runner.run_playoff_only(team_count: options[:team_count], stop_at: options[:stop_at] || :completed)
when 'group_stage_render_profile'

View File

@ -898,6 +898,119 @@ RSpec.describe TournamentsController, type: :controller do
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
before do
@tournament.update!(read_only_mode: true, sync_auth_token: 'shared-secret')

View File

@ -277,6 +277,64 @@ RSpec.describe 'Tournament follower sync HTTP E2E' do
expect(takeover_update[:status]).to eq(200)
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)
puts "phase #{message}"
$stdout.flush
@ -291,7 +349,10 @@ RSpec.describe 'Tournament follower sync HTTP E2E' do
client
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 = {
name: "#{name_prefix} #{unique_suffix}",
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',
website_url: 'https://example.com/sync-source',
group_stage: true,
playoff_teams_amount: 8,
teams: 4.times.flat_map do |group_index|
4.times.map do |seed_index|
playoff_teams_amount: playoff_teams_amount,
teams: group_count.times.flat_map do |group_index|
teams_per_group.times.map do |seed_index|
{
name: "#{name_prefix.tr(' ', '')}-G#{group_index + 1}-S#{seed_index + 1}",
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))
end
def sync_state_url(follower_id)
"#{follower_base_url}/tournaments/#{follower_id}/sync_state"
end
def create_playoff_tournament(client:, public:, name_prefix:)
payload = {
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)
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:)
loop do
tournament = fetch_tournament(client: source_anonymous_client, tournament_id: source_id)

View File

@ -61,6 +61,27 @@ RSpec.describe 'Tournament lifecycle HTTP E2E' do
.find { |stage| stage[:level] == 0 }[:match_states]).to eq(%w[finished finished])
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],
lost: [2, 1],

View File

@ -28,6 +28,10 @@ RSpec.describe TournamentsController, type: :routing do
expect(delete: '/tournaments/1').to route_to('tournaments#destroy', id: '1')
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
expect(patch: '/tournaments/1/sync_state').to route_to('tournaments#sync_state', id: '1')
end

View File

@ -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")
def scenario_playoff(
c,