diff --git a/app/controllers/matches_controller.rb b/app/controllers/matches_controller.rb index 0d605fd..ce43a3d 100644 --- a/app/controllers/matches_controller.rb +++ b/app/controllers/matches_controller.rb @@ -1,13 +1,14 @@ # frozen_string_literal: true class MatchesController < ApplicationController - before_action :set_match, only: %i[show update] + before_action :set_match, only: %i[show update swap] before_action :set_group, only: %i[create] before_action :validate_params, only: %i[update] - before_action -> { require_owner! @match.owner }, only: %i[update] + before_action :set_swap_target, only: %i[swap] + before_action -> { require_owner! @match.owner }, only: %i[update swap] before_action -> { require_owner! @group.owner }, only: %i[create] before_action :set_tournament, only: %i[index] - before_action -> { require_writable_tournament!(@match.tournament) }, only: %i[update] + before_action -> { require_writable_tournament!(@match.tournament) }, only: %i[update swap] before_action -> { require_writable_tournament!(@group.tournament) }, only: %i[create] # GET/tournaments/1/matches @@ -88,6 +89,30 @@ class MatchesController < ApplicationController push_sync_if_needed!(synced_tournament) unless synced_tournament.nil? end + # PATCH /matches/1/swap + def swap + unless @match.swappable_with?(@swap_target) + render json: { error: 'Matches must be different group matches from the same group' }, status: :unprocessable_entity + return + end + + synced_tournament = nil + + Match.transaction do + source_position = @match.position + target_position = @swap_target.position + + @match.update!(position: target_position) + @swap_target.update!(position: source_position) + + synced_tournament = @match.tournament + end + + swapped_matches = Match.where(id: [@match.id, @swap_target.id]).order(:position) + render json: swapped_matches, each_serializer: ExtendedMatchSerializer, include: %w[match_scores.team bets stage group] + push_sync_if_needed!(synced_tournament) + end + private def handle_match_end @@ -134,6 +159,13 @@ class MatchesController < ApplicationController @tournament = Tournament.find(params[:tournament_id]) end + def set_swap_target + @swap_target = Match.find_by(id: params[:other_match_id]) + return unless @swap_target.nil? + + render json: { error: 'Match not found' }, status: :not_found + end + def set_group @group = Group.find(params[:group_id]) end diff --git a/app/models/match.rb b/app/models/match.rb index 2a84518..af4adee 100644 --- a/app/models/match.rb +++ b/app/models/match.rb @@ -50,6 +50,12 @@ class Match < ApplicationRecord group.present? end + def swappable_with?(other_match) + return false if other_match.nil? + + group_match? && other_match.group_match? && group_id == other_match.group_id && id != other_match.id + end + def scored_points_of(team) return 0 if decider? return 0 unless (finished? || in_progress?) && teams.include?(team) diff --git a/config/routes.rb b/config/routes.rb index fdb467b..b3dcc6e 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -7,6 +7,9 @@ Rails.application.routes.draw do } resources :matches, only: %i[show update] do + member do + patch :swap + end resources :bets, only: %i[index create] end resources :groups, only: %i[show] do diff --git a/spec/controllers/matches_controller_spec.rb b/spec/controllers/matches_controller_spec.rb index 6e270dc..3641514 100644 --- a/spec/controllers/matches_controller_spec.rb +++ b/spec/controllers/matches_controller_spec.rb @@ -96,6 +96,72 @@ RSpec.describe MatchesController, type: :controller do end end + describe 'PATCH #swap' do + let(:group_stage_tournament) { create(:group_stage_tournament, group_count: 2, match_factory: :filled_group_match) } + let(:group_stage) { group_stage_tournament.stages.find_by(level: -1) } + let(:first_group) { group_stage.groups.order(:id).first } + let(:second_group) { group_stage.groups.order(:id).second } + let(:first_match) do + first_group.matches.order(:id).first.tap do |match| + match.update!(position: 0) + end + end + let(:last_match) do + first_group.matches.order(:id).last.tap do |match| + match.update!(position: 3) + end + end + + context 'as owner' do + before do + apply_authentication_headers_for group_stage_tournament.owner + end + + it 'swaps positions of two matches from same group' do + original_first_position = first_match.position + original_last_position = last_match.position + + patch :swap, params: { id: first_match.to_param, other_match_id: last_match.to_param } + + expect(response).to be_successful + expect(first_match.reload.position).to eq(original_last_position) + expect(last_match.reload.position).to eq(original_first_position) + + body = deserialize_response(response) + expect(body.map { |match| match[:id] }).to match_array([first_match.id, last_match.id]) + expect(body.map { |match| match[:position] }).to match_array([original_first_position, original_last_position]) + end + + it 'rejects matches from different groups' do + foreign_match = second_group.matches.order(:id).first + + patch :swap, params: { id: first_match.to_param, other_match_id: foreign_match.to_param } + + expect(response).to have_http_status(:unprocessable_entity) + expect(deserialize_response(response)[:error]).to eq('Matches must be different group matches from the same group') + end + + it 'rejects missing swap target' do + patch :swap, params: { id: first_match.to_param, other_match_id: Match.maximum(:id).to_i + 1 } + + expect(response).to have_http_status(:not_found) + expect(deserialize_response(response)[:error]).to eq('Match not found') + end + end + + context 'as another user' do + before do + apply_authentication_headers_for create(:user) + end + + it 'renders a forbidden error response' do + patch :swap, params: { id: first_match.to_param, other_match_id: last_match.to_param } + + expect(response).to have_http_status(:forbidden) + end + end + end + describe 'POST #update' do context 'on a running playoff match' do let(:valid_update) do diff --git a/spec/e2e/http/api_surface_spec.rb b/spec/e2e/http/api_surface_spec.rb index 2ca0df9..7829092 100644 --- a/spec/e2e/http/api_surface_spec.rb +++ b/spec/e2e/http/api_surface_spec.rb @@ -2,6 +2,7 @@ require 'spec_helper' require 'securerandom' +require 'time' require_relative '../../../e2e/lib/api_client' RSpec.describe 'Backend API surface HTTP E2E' do @@ -300,6 +301,47 @@ RSpec.describe 'Backend API surface HTTP E2E' do expect(statistics_after.dig(:json, :least_dominant_score, :id)).to be_a(Integer) end + it 'swaps group match positions and keeps upcoming order consistent afterwards' do + tournament = create_group_stage_tournament(client: owner_client, public: false, name_prefix: 'Swap Matches') + group_stage = tournament.fetch(:stages).find { |stage| stage.fetch(:level) == -1 } + 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' }) + swap_response = owner_client.patch("/matches/#{first_match.fetch(:id)}/swap", body: { + other_match_id: fourth_match.fetch(:id) + }) + swapped_tournament = fetch_tournament(client: owner_client, tournament_id: tournament.fetch(:id)) + swapped_group = swapped_tournament.fetch(:stages) + .find { |stage| stage.fetch(:level) == -1 } + .fetch(:groups) + .find { |item| item.fetch(:id) == group.fetch(:id) } + swapped_matches = swapped_group.fetch(:matches).sort_by { |item| item.fetch(:position) } + upcoming_after_swap = owner_client.get("/tournaments/#{tournament.fetch(:id)}/matches", params: { state: 'upcoming' }) + + finish_group_match_with_winner!(match_id: fourth_match.fetch(:id)) + + upcoming_after_finish = owner_client.get("/tournaments/#{tournament.fetch(:id)}/matches", params: { state: 'upcoming' }) + + expect(upcoming_before[:status]).to eq(200) + expect(upcoming_match_for_group(upcoming_before, group_id: group.fetch(:id)).fetch(:id)).to eq(first_match.fetch(:id)) + + expect(swap_response[:status]).to eq(200) + expect(swap_response[:json].map { |match| match[:id] }).to match_array([first_match.fetch(:id), fourth_match.fetch(:id)]) + + expect(swapped_matches.first.fetch(:id)).to eq(fourth_match.fetch(:id)) + expect(swapped_matches.first.fetch(:position)).to eq(first_match.fetch(:position)) + expect(swapped_matches.find { |match| match.fetch(:id) == first_match.fetch(:id) }.fetch(:position)).to eq(fourth_match.fetch(:position)) + expect(upcoming_after_swap[:status]).to eq(200) + 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)) + end + def login_client(email:, password:) client = TurniereE2E::ApiClient.new(base_url: base_url) response = client.login!(email: email, password: password) @@ -401,6 +443,10 @@ RSpec.describe 'Backend API surface HTTP E2E' do end end + def upcoming_match_for_group(response, group_id:) + response.fetch(:json).find { |match| match.dig(:group, :id) == group_id } + end + def unique_suffix @unique_suffix_counter ||= 0 @unique_suffix_counter += 1 diff --git a/spec/e2e/http/tournament_follow_sync_spec.rb b/spec/e2e/http/tournament_follow_sync_spec.rb index 1c2efa7..2985472 100644 --- a/spec/e2e/http/tournament_follow_sync_spec.rb +++ b/spec/e2e/http/tournament_follow_sync_spec.rb @@ -50,6 +50,65 @@ RSpec.describe 'Tournament follower sync HTTP E2E' do expect(tournament_signature(follower)).to eq(tournament_signature(source)) expect(synced_team_id_pairs(source: source, follower: follower)).not_to be_empty + log_phase!('swap matches on leader and verify follower behavior') + source_group_stage = source.fetch(:stages).find { |stage| stage.fetch(:level) == -1 } + first_source_group = source_group_stage.fetch(:groups).sort_by { |group| group.fetch(:number) }.first + second_source_group = source_group_stage.fetch(:groups).sort_by { |group| group.fetch(:number) }[1] + first_group_matches = first_source_group.fetch(:matches).sort_by { |match| match.fetch(:position) } + first_position_match = first_group_matches.fetch(0) + fourth_position_match = first_group_matches.fetch(3) + wrong_group_match = second_source_group.fetch(:matches).sort_by { |match| match.fetch(:position) }.first + follower_group_stage = follower.fetch(:stages).find { |stage| stage.fetch(:level) == -1 } + first_follower_group = follower_group_stage.fetch(:groups).sort_by { |group| group.fetch(:number) }.first + follower_first_position_match = first_follower_group.fetch(:matches).find do |match| + match_team_names(match) == match_team_names(first_position_match) + end + follower_fourth_position_match = first_follower_group.fetch(:matches).find do |match| + match_team_names(match) == match_team_names(fourth_position_match) + end + + leader_swap = source_owner_client.patch("/matches/#{first_position_match.fetch(:id)}/swap", body: { + other_match_id: fourth_position_match.fetch(:id) + }) + expect(leader_swap[:status]).to eq(200) + expect(leader_swap[:json].map { |match| match.fetch(:position) }).to match_array([ + first_position_match.fetch(:position), + fourth_position_match.fetch(:position) + ]) + 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(tournament_signature(follower)).to eq(tournament_signature(source)) + expect(group_match_positions(source, group_number: first_source_group.fetch(:number))).to eq( + group_match_positions(follower, group_number: first_follower_group.fetch(:number)) + ) + expect(group_match_positions(source, group_number: first_source_group.fetch(:number)).fetch(match_team_names(first_position_match))).to eq( + fourth_position_match.fetch(:position) + ) + expect(group_match_positions(source, group_number: first_source_group.fetch(:number)).fetch(match_team_names(fourth_position_match))).to eq( + first_position_match.fetch(:position) + ) + + follower_before_invalid_swap = tournament_signature(follower) + source_before_invalid_swap = tournament_signature(source) + + wrong_group_swap = source_owner_client.patch("/matches/#{first_position_match.fetch(:id)}/swap", body: { + other_match_id: wrong_group_match.fetch(:id) + }) + expect(wrong_group_swap[:status]).to eq(422) + expect(wrong_group_swap.dig(:json, :error)).to eq('Matches must be different group matches from the same group') + source_after_invalid_swap = fetch_tournament(client: source_anonymous_client, tournament_id: source.fetch(:id)) + follower_after_invalid_swap = fetch_tournament(client: follower_anonymous_client, tournament_id: follower.fetch(:id)) + expect(tournament_signature(source_after_invalid_swap)).to eq(source_before_invalid_swap) + expect(tournament_signature(follower_after_invalid_swap)).to eq(follower_before_invalid_swap) + + follower_swap = follower_owner_client.patch("/matches/#{follower_first_position_match.fetch(:id)}/swap", body: { + other_match_id: follower_fourth_position_match.fetch(:id) + }) + expect(follower_swap[:status]).to eq(423) + expect(follower_swap.dig(:json, :error)).to eq('Tournament is in read only follower mode') + follower_after_locked_swap = fetch_tournament(client: follower_anonymous_client, tournament_id: follower.fetch(:id)) + expect(tournament_signature(follower_after_locked_swap)).to eq(follower_before_invalid_swap) + log_phase!('tighten playoff cutoff and rename teams') update_cutoff = source_owner_client.patch("/tournaments/#{source.fetch(:id)}", body: { playoff_teams_amount: 4, @@ -427,6 +486,24 @@ RSpec.describe 'Tournament follower sync HTTP E2E' do } end + def match_team_names(match) + match.fetch(:match_scores) + .map { |score| score.dig(:team, :name) } + .sort + .join(' vs ') + end + + def group_match_positions(tournament, group_number:) + group = tournament.fetch(:stages) + .find { |stage| stage.fetch(:level) == -1 } + .fetch(:groups) + .find { |candidate| candidate.fetch(:number) == group_number } + + group.fetch(:matches).each_with_object({}) do |match, positions| + positions[match_team_names(match)] = match.fetch(:position) + end + end + def unique_suffix @unique_suffix_counter ||= 0 @unique_suffix_counter += 1 diff --git a/spec/routing/matches_routing_spec.rb b/spec/routing/matches_routing_spec.rb index 9b5918d..ac3a659 100644 --- a/spec/routing/matches_routing_spec.rb +++ b/spec/routing/matches_routing_spec.rb @@ -11,5 +11,9 @@ RSpec.describe MatchesController, type: :routing do it 'routes nested group matches to #create' do expect(post: '/groups/1/matches').to route_to('matches#create', group_id: '1') end + + it 'routes to #swap' do + expect(patch: '/matches/1/swap').to route_to('matches#swap', id: '1') + end end end