Add backend group match swap endpoint

This commit is contained in:
Daniel Schädler 2026-04-22 15:48:06 +02:00
parent 9547cce982
commit 99538a3343
6 changed files with 160 additions and 3 deletions

View File

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

View File

@ -43,6 +43,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)

View File

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

View File

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

View File

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

View File

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