Merge branch 'TUR-52-backend-match-swap' into 'master'

TUR-52 backend match swap endpoint

Closes TUR-52

See merge request turniere/turniere-backend!48
This commit is contained in:
Daniel Schädler 2026-04-22 15:10:45 +00:00
commit ad96668a04
7 changed files with 237 additions and 3 deletions

View File

@ -1,13 +1,14 @@
# frozen_string_literal: true # frozen_string_literal: true
class MatchesController < ApplicationController 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 :set_group, only: %i[create]
before_action :validate_params, only: %i[update] 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 -> { require_owner! @group.owner }, only: %i[create]
before_action :set_tournament, only: %i[index] 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] before_action -> { require_writable_tournament!(@group.tournament) }, only: %i[create]
# GET/tournaments/1/matches # GET/tournaments/1/matches
@ -88,6 +89,30 @@ class MatchesController < ApplicationController
push_sync_if_needed!(synced_tournament) unless synced_tournament.nil? push_sync_if_needed!(synced_tournament) unless synced_tournament.nil?
end 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 private
def handle_match_end def handle_match_end
@ -134,6 +159,13 @@ class MatchesController < ApplicationController
@tournament = Tournament.find(params[:tournament_id]) @tournament = Tournament.find(params[:tournament_id])
end 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 def set_group
@group = Group.find(params[:group_id]) @group = Group.find(params[:group_id])
end end

View File

@ -50,6 +50,12 @@ class Match < ApplicationRecord
group.present? group.present?
end 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) def scored_points_of(team)
return 0 if decider? return 0 if decider?
return 0 unless (finished? || in_progress?) && teams.include?(team) return 0 unless (finished? || in_progress?) && teams.include?(team)

View File

@ -7,6 +7,9 @@ Rails.application.routes.draw do
} }
resources :matches, only: %i[show update] do resources :matches, only: %i[show update] do
member do
patch :swap
end
resources :bets, only: %i[index create] resources :bets, only: %i[index create]
end end
resources :groups, only: %i[show] do resources :groups, only: %i[show] do

View File

@ -96,6 +96,72 @@ RSpec.describe MatchesController, type: :controller do
end end
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 describe 'POST #update' do
context 'on a running playoff match' do context 'on a running playoff match' do
let(:valid_update) do let(:valid_update) do

View File

@ -2,6 +2,7 @@
require 'spec_helper' require 'spec_helper'
require 'securerandom' require 'securerandom'
require 'time'
require_relative '../../../e2e/lib/api_client' require_relative '../../../e2e/lib/api_client'
RSpec.describe 'Backend API surface HTTP E2E' do 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) expect(statistics_after.dig(:json, :least_dominant_score, :id)).to be_a(Integer)
end 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:) def login_client(email:, password:)
client = TurniereE2E::ApiClient.new(base_url: base_url) client = TurniereE2E::ApiClient.new(base_url: base_url)
response = client.login!(email: email, password: password) response = client.login!(email: email, password: password)
@ -401,6 +443,10 @@ RSpec.describe 'Backend API surface HTTP E2E' do
end end
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 def unique_suffix
@unique_suffix_counter ||= 0 @unique_suffix_counter ||= 0
@unique_suffix_counter += 1 @unique_suffix_counter += 1

View File

@ -50,6 +50,65 @@ RSpec.describe 'Tournament follower sync HTTP E2E' do
expect(tournament_signature(follower)).to eq(tournament_signature(source)) expect(tournament_signature(follower)).to eq(tournament_signature(source))
expect(synced_team_id_pairs(source: source, follower: follower)).not_to be_empty 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') log_phase!('tighten playoff cutoff and rename teams')
update_cutoff = source_owner_client.patch("/tournaments/#{source.fetch(:id)}", body: { update_cutoff = source_owner_client.patch("/tournaments/#{source.fetch(:id)}", body: {
playoff_teams_amount: 4, playoff_teams_amount: 4,
@ -427,6 +486,24 @@ RSpec.describe 'Tournament follower sync HTTP E2E' do
} }
end 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 def unique_suffix
@unique_suffix_counter ||= 0 @unique_suffix_counter ||= 0
@unique_suffix_counter += 1 @unique_suffix_counter += 1

View File

@ -11,5 +11,9 @@ RSpec.describe MatchesController, type: :routing do
it 'routes nested group matches to #create' do it 'routes nested group matches to #create' do
expect(post: '/groups/1/matches').to route_to('matches#create', group_id: '1') expect(post: '/groups/1/matches').to route_to('matches#create', group_id: '1')
end end
it 'routes to #swap' do
expect(patch: '/matches/1/swap').to route_to('matches#swap', id: '1')
end
end end
end end