diff --git a/app/controllers/tournaments_controller.rb b/app/controllers/tournaments_controller.rb index bc29529..9478dc6 100644 --- a/app/controllers/tournaments_controller.rb +++ b/app/controllers/tournaments_controller.rb @@ -12,15 +12,18 @@ class TournamentsController < ApplicationController end before_action :set_tournament_for_show, only: %i[show] - before_action :set_tournament, only: %i[update destroy set_timer timer sync_state test_sync] - before_action :authenticate_user!, only: %i[create update destroy set_timer test_sync] - before_action -> { require_owner! @tournament.owner }, only: %i[update destroy set_timer test_sync] + 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] + before_action -> { require_owner! @tournament.owner }, + only: %i[update destroy 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 :authorize_sync_request!, only: %i[sync_state] rescue_from ActiveRecord::RecordNotFound, with: :render_not_found_error @@ -165,6 +168,20 @@ class TournamentsController < ApplicationController }, status: :bad_gateway end + # PATCH /tournaments/:id/start_next_group_matches + def start_next_group_matches + started_matches = TournamentGroupMatchStarter.start_next!(@tournament, logger:) + if started_matches.any? + push_sync_if_needed!(@tournament) + TournamentLiveBroadcast.call(@tournament.id) + end + + render json: ActiveModelSerializers::SerializableResource.new( + started_matches, + each_serializer: LiveMatchSerializer, + include: %w[match_scores.team] + ).as_json + end private diff --git a/app/services/tournament_group_match_starter.rb b/app/services/tournament_group_match_starter.rb new file mode 100644 index 0000000..0fdd7d5 --- /dev/null +++ b/app/services/tournament_group_match_starter.rb @@ -0,0 +1,41 @@ +# frozen_string_literal: true + +class TournamentGroupMatchStarter + class << self + def upcoming_matches(tournament) + group_stage = tournament.group_stage + return [] if group_stage.nil? + + next_matches = group_stage.groups.filter_map do |group| + group.matches.not_started.order(:position).first + end + lowest_position = next_matches.map(&:position).min + return [] if lowest_position.nil? + + next_matches.select { |match| match.position == lowest_position } + .sort_by { |match| [match.position, match.group.number, match.id] } + end + + def start_next!(tournament, logger: Rails.logger) + started_matches = [] + + Match.transaction do + started_matches = upcoming_matches(tournament) + started_matches.each { |match| match.update!(state: :in_progress) } + recalculate_group_scores!(started_matches, logger:) + end + + started_matches + end + + private + + def recalculate_group_scores!(matches, logger:) + matches.map(&:group).uniq.each do |group| + next if UpdateGroupsGroupScoresAndSave.call(group:).success? + + logger.warn "Updating groups group score failed for #{group}" + end + end + end +end diff --git a/app/services/tournament_matches_payload.rb b/app/services/tournament_matches_payload.rb index dea06cc..2a8fedb 100644 --- a/app/services/tournament_matches_payload.rb +++ b/app/services/tournament_matches_payload.rb @@ -79,12 +79,7 @@ class TournamentMatchesPayload end def upcoming_group_matches(tournament) - group_stage = tournament.stages.find { |stage| stage.level == -1 } - return [] if group_stage.nil? - - group_stage.groups.filter_map do |group| - group.matches.select { |match| match.state == 'not_started' }.min_by(&:position) - end + TournamentGroupMatchStarter.upcoming_matches(tournament) end def upcoming_playoff_matches(tournament) diff --git a/config/routes.rb b/config/routes.rb index 74b165e..ce2aeb8 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -34,6 +34,7 @@ Rails.application.routes.draw do get :timer patch :timer, action: :set_timer patch :sync_state + patch :start_next_group_matches post :test_sync end end diff --git a/spec/controllers/tournaments_controller_spec.rb b/spec/controllers/tournaments_controller_spec.rb index 2f95deb..4777793 100644 --- a/spec/controllers/tournaments_controller_spec.rb +++ b/spec/controllers/tournaments_controller_spec.rb @@ -888,4 +888,54 @@ RSpec.describe TournamentsController, type: :controller do expect(json.dig(:tournament, :sync_last_push_error)).to eq('connection refused') end end + + describe 'PATCH #start_next_group_matches' do + let(:tournament) { create(:group_stage_tournament, group_count: 4, match_factory: :filled_group_match) } + let(:groups) { tournament.group_stage.groups.sort_by(&:number) } + + before do + set_group_match_states(groups[0], %i[finished finished finished in_progress]) + groups.drop(1).each do |group| + set_group_match_states(group, %i[finished finished in_progress not_started]) + end + end + + context 'as owner' do + before do + apply_authentication_headers_for tournament.owner + end + + it 'starts only lowest pending catch-up matches and returns them' do + expect(TournamentLiveBroadcast).to receive(:call).with(tournament.id) + + patch :start_next_group_matches, params: { id: tournament.to_param } + + expect(response).to have_http_status(:ok) + body = deserialize_response(response) + expect(body.map { |match| match.dig(:group, :id) }).to match_array(groups.drop(1).map(&:id)) + expect(body.map { |match| match[:position] }.uniq).to eq([3]) + expect(body.map { |match| match[:state] }.uniq).to eq(['in_progress']) + expect(groups.drop(1).map { |group| group.matches.find_by!(position: 3).reload.state }.uniq) + .to eq(['in_progress']) + end + end + + context 'as another user' do + before do + apply_authentication_headers_for create(:user) + end + + it 'renders a forbidden error response' do + patch :start_next_group_matches, params: { id: tournament.to_param } + + expect(response).to have_http_status(:forbidden) + end + end + + def set_group_match_states(group, states) + group.matches.order(:id).each_with_index do |match, index| + match.update!(position: index, state: states.fetch(index)) + end + end + end end diff --git a/spec/e2e/http/api_surface_spec.rb b/spec/e2e/http/api_surface_spec.rb index 8575991..61301d5 100644 --- a/spec/e2e/http/api_surface_spec.rb +++ b/spec/e2e/http/api_surface_spec.rb @@ -376,7 +376,6 @@ RSpec.describe 'Backend API surface HTTP E2E' do group = group_stage.fetch(:groups).min_by { |item| item.fetch(:number) } ordered_matches = group.fetch(:matches).sort_by { |item| item.fetch(:position) } first_match = ordered_matches.fetch(0) - second_match = ordered_matches.fetch(1) fourth_match = ordered_matches.fetch(3) upcoming_before = owner_client.get("/tournaments/#{tournament.fetch(:id)}/matches", params: { state: 'upcoming' }) @@ -408,7 +407,48 @@ RSpec.describe 'Backend API surface HTTP E2E' do expect(upcoming_match_for_group(upcoming_after_swap, group_id: group.fetch(:id)).fetch(:id)).to eq(fourth_match.fetch(:id)) expect(upcoming_after_finish[:status]).to eq(200) - expect(upcoming_match_for_group(upcoming_after_finish, group_id: group.fetch(:id)).fetch(:id)).to eq(second_match.fetch(:id)) + expect(upcoming_match_for_group(upcoming_after_finish, group_id: group.fetch(:id))).to be_nil + expect(upcoming_after_finish[:json].map { |match| match.fetch(:position) }.uniq) + .to eq([first_match.fetch(:position)]) + end + + it 'starts next group matches at the lowest pending position and returns started matches' do + tournament = create_group_stage_tournament(client: owner_client, public: false, name_prefix: 'Start Next Groups') + groups = tournament.fetch(:stages).find { |stage| stage.fetch(:level) == -1 } + .fetch(:groups) + .sort_by { |group| group.fetch(:number) } + matches_by_group = groups.to_h do |group| + [group.fetch(:id), group.fetch(:matches).sort_by { |match| match.fetch(:position) }] + end + + first_start = owner_client.patch("/tournaments/#{tournament.fetch(:id)}/start_next_group_matches") + expect(first_start[:status]).to eq(200) + expect(first_start[:json].map { |match| match.dig(:group, :id) }) + .to match_array(groups.map { |group| group.fetch(:id) }) + expect(first_start[:json].map { |match| match.fetch(:position) }.uniq).to eq([0]) + + matches_by_group.fetch(groups.first.fetch(:id))[1..3].each do |match| + start_group_match!(match_id: match.fetch(:id)) + end + groups.drop(1).each do |group| + matches_by_group.fetch(group.fetch(:id))[1..2].each do |match| + start_group_match!(match_id: match.fetch(:id)) + end + end + + expected_match_ids = groups.drop(1).map do |group| + matches_by_group.fetch(group.fetch(:id)).fetch(3).fetch(:id) + end + upcoming = owner_client.get("/tournaments/#{tournament.fetch(:id)}/matches", params: { state: 'upcoming' }) + catch_up_start = owner_client.patch("/tournaments/#{tournament.fetch(:id)}/start_next_group_matches") + + expect(upcoming[:status]).to eq(200) + expect(upcoming[:json].map { |match| match.fetch(:id) }).to match_array(expected_match_ids) + + expect(catch_up_start[:status]).to eq(200) + expect(catch_up_start[:json].map { |match| match.fetch(:id) }).to match_array(expected_match_ids) + expect(catch_up_start[:json].map { |match| match.fetch(:position) }.uniq).to eq([3]) + expect(catch_up_start[:json].map { |match| match.fetch(:state) }.uniq).to eq(['in_progress']) end def login_client(email:, password:) @@ -506,6 +546,11 @@ RSpec.describe 'Backend API surface HTTP E2E' do expect(finish_response[:status]).to eq(200) end + def start_group_match!(match_id:) + start_response = owner_client.patch("/matches/#{match_id}", body: { state: 'in_progress' }) + expect(start_response[:status]).to eq(200) + end + def tournament_match_count(tournament) tournament.fetch(:stages).sum do |stage| stage.fetch(:matches, []).count + stage.fetch(:groups, []).sum { |group| group.fetch(:matches).count } diff --git a/spec/routing/tournaments_routing_spec.rb b/spec/routing/tournaments_routing_spec.rb index 8c30c0d..3cb398a 100644 --- a/spec/routing/tournaments_routing_spec.rb +++ b/spec/routing/tournaments_routing_spec.rb @@ -28,12 +28,18 @@ RSpec.describe TournamentsController, type: :routing do expect(delete: '/tournaments/1').to route_to('tournaments#destroy', id: '1') end - it 'routes to #sync_state' do - expect(patch: '/tournaments/1/sync_state').to route_to('tournaments#sync_state', id: '1') - end + it 'routes to #sync_state' do + expect(patch: '/tournaments/1/sync_state').to route_to('tournaments#sync_state', id: '1') + end - it 'routes to #test_sync' do - expect(post: '/tournaments/1/test_sync').to route_to('tournaments#test_sync', id: '1') + it 'routes to #start_next_group_matches' do + expect(patch: '/tournaments/1/start_next_group_matches').to( + route_to('tournaments#start_next_group_matches', id: '1') + ) + end + + it 'routes to #test_sync' do + expect(post: '/tournaments/1/test_sync').to route_to('tournaments#test_sync', id: '1') + end end end -end diff --git a/spec/services/tournament_group_match_starter_spec.rb b/spec/services/tournament_group_match_starter_spec.rb new file mode 100644 index 0000000..77f2b24 --- /dev/null +++ b/spec/services/tournament_group_match_starter_spec.rb @@ -0,0 +1,41 @@ +# frozen_string_literal: true + +require 'rails_helper' + +RSpec.describe TournamentGroupMatchStarter do + let(:tournament) { create(:group_stage_tournament, group_count: 4, match_factory: :filled_group_match) } + let(:groups) { tournament.group_stage.groups.sort_by(&:number) } + + before do + set_group_match_states(groups[0], %i[finished finished finished in_progress]) + groups.drop(1).each do |group| + set_group_match_states(group, %i[finished finished in_progress not_started]) + end + end + + describe '.upcoming_matches' do + it 'returns only groups whose next unstarted match is at the lowest pending position' do + matches = described_class.upcoming_matches(tournament) + + expect(matches.map(&:group)).to match_array(groups.drop(1)) + expect(matches.map(&:position).uniq).to eq([3]) + end + end + + describe '.start_next!' do + it 'starts selected catch-up matches and returns them' do + started_matches = described_class.start_next!(tournament, logger: Logger.new(nil)) + + expect(started_matches.map(&:group)).to match_array(groups.drop(1)) + expect(started_matches.map(&:position).uniq).to eq([3]) + expect(started_matches.map { |match| match.reload.state }.uniq).to eq(['in_progress']) + expect(groups.first.matches.find_by!(position: 3).reload).to be_in_progress + end + end + + def set_group_match_states(group, states) + group.matches.order(:id).each_with_index do |match, index| + match.update!(position: index, state: states.fetch(index)) + end + end +end diff --git a/spec/services/tournament_live_payload_spec.rb b/spec/services/tournament_live_payload_spec.rb index 21cb34b..8e5781d 100644 --- a/spec/services/tournament_live_payload_spec.rb +++ b/spec/services/tournament_live_payload_spec.rb @@ -49,6 +49,21 @@ RSpec.describe 'Tournament live payloads' do expect(matches.map(&:group_id)).to match_array(tournament.group_stage.groups.map(&:id)) end + it 'returns only catch-up group matches for upcoming state' do + tournament = create(:group_stage_tournament, group_count: 4, match_factory: :filled_group_match) + groups = tournament.group_stage.groups.sort_by(&:number) + + set_group_match_states(groups[0], %i[finished finished finished in_progress]) + groups.drop(1).each do |group| + set_group_match_states(group, %i[finished finished in_progress not_started]) + end + + matches = described_class.matches_for(tournament, state: 'upcoming') + + expect(matches.map(&:group_id)).to match_array(groups.drop(1).map(&:id)) + expect(matches.map(&:position).uniq).to eq([3]) + end + it 'filters all matches belonging to a stage level' do tournament = create(:stage_tournament, stage_count: 2) stage = tournament.stages.find_by!(level: 1) @@ -106,4 +121,10 @@ RSpec.describe 'Tournament live payloads' do ) end end + + def set_group_match_states(group, states) + group.matches.order(:id).each_with_index do |match, index| + match.update!(position: index, state: states.fetch(index)) + end + end end diff --git a/tasks.py b/tasks.py index 69986f7..1dc45bd 100644 --- a/tasks.py +++ b/tasks.py @@ -537,7 +537,7 @@ def _start_server(command, env, log_path="/tmp/turniere-e2e-server.log"): def _start_test_server(host=SERVER_HOST, port=SERVER_PORT): return _start_server( f"{_bundle_exec('rails', 's')} -e test -b {host} -p {port}", - _env(), + _env(GIT_COMMIT_SHA=_git_commit_sha()), )