diff --git a/app/controllers/tournaments_controller.rb b/app/controllers/tournaments_controller.rb index 89d6940..e2cc26c 100644 --- a/app/controllers/tournaments_controller.rb +++ b/app/controllers/tournaments_controller.rb @@ -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| diff --git a/app/services/tournament_playoff_deletion.rb b/app/services/tournament_playoff_deletion.rb new file mode 100644 index 0000000..fbeafd8 --- /dev/null +++ b/app/services/tournament_playoff_deletion.rb @@ -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 diff --git a/config/routes.rb b/config/routes.rb index ba25f79..c3d4396 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -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] diff --git a/e2e/lib/scenario_runner.rb b/e2e/lib/scenario_runner.rb index dace468..cee171a 100644 --- a/e2e/lib/scenario_runner.rb +++ b/e2e/lib/scenario_runner.rb @@ -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' }) diff --git a/script/e2e_scenarios.rb b/script/e2e_scenarios.rb index fad48d8..45da011 100644 --- a/script/e2e_scenarios.rb +++ b/script/e2e_scenarios.rb @@ -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' diff --git a/spec/controllers/tournaments_controller_spec.rb b/spec/controllers/tournaments_controller_spec.rb index ce470d9..bfeefbf 100644 --- a/spec/controllers/tournaments_controller_spec.rb +++ b/spec/controllers/tournaments_controller_spec.rb @@ -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') diff --git a/spec/e2e/http/tournament_follow_sync_spec.rb b/spec/e2e/http/tournament_follow_sync_spec.rb index bdbc40a..8fac3fc 100644 --- a/spec/e2e/http/tournament_follow_sync_spec.rb +++ b/spec/e2e/http/tournament_follow_sync_spec.rb @@ -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) diff --git a/spec/e2e/http/tournament_lifecycle_spec.rb b/spec/e2e/http/tournament_lifecycle_spec.rb index 437c063..ea5ff0a 100644 --- a/spec/e2e/http/tournament_lifecycle_spec.rb +++ b/spec/e2e/http/tournament_lifecycle_spec.rb @@ -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], diff --git a/spec/routing/tournaments_routing_spec.rb b/spec/routing/tournaments_routing_spec.rb index 0c185eb..77b85bb 100644 --- a/spec/routing/tournaments_routing_spec.rb +++ b/spec/routing/tournaments_routing_spec.rb @@ -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 diff --git a/tasks.py b/tasks.py index ebc5e2f..d8c02e0 100644 --- a/tasks.py +++ b/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") def scenario_playoff( c,