Merge branch 'codex/tur-117-match-filters' into 'master'

TUR-117: Add match endpoint filters

See merge request turniere/turniere-backend!60
This commit is contained in:
Daniel Schädler 2026-04-26 20:23:27 +00:00
commit 244952093c
10 changed files with 204 additions and 28 deletions

View File

@ -2,10 +2,28 @@
class TournamentMatchesChannel < ApplicationCable::Channel class TournamentMatchesChannel < ApplicationCable::Channel
def subscribed def subscribed
tournament = Tournament.find(params[:tournament_id]) @tournament_id = params[:tournament_id]
state = params[:state].presence @filters = match_filters
tournament = Tournament.find(@tournament_id)
stream_for TournamentMatchesBroadcast.stream_target(tournament, state:) stream_for TournamentMatchesBroadcast.stream_target(tournament) do
transmit TournamentMatchesBroadcastPayload.build(tournament, state:) transmit_current_payload
end
transmit TournamentMatchesBroadcastPayload.build(tournament, @filters)
end
private
attr_reader :tournament_id, :filters
def match_filters
params.slice(:state, :stage_id, :stage_level, :position, :group_stage_position)
end
def transmit_current_payload
tournament = TournamentMatchesBroadcast.load_tournament(tournament_id)
return if tournament.nil?
transmit TournamentMatchesBroadcastPayload.build(tournament, filters)
end end
end end

View File

@ -13,7 +13,7 @@ class MatchesController < ApplicationController
# GET/tournaments/1/matches # GET/tournaments/1/matches
def index def index
render json: TournamentMatchesPayload.build(@tournament, state: match_params['state']) render json: TournamentMatchesPayload.build(@tournament, match_index_params)
end end
# GET /matches/1 # GET /matches/1
@ -149,6 +149,10 @@ class MatchesController < ApplicationController
params.slice(:state).permit! params.slice(:state).permit!
end end
def match_index_params
params.slice(:state, :stage_id, :stage_level, :position, :group_stage_position).permit!
end
def create_match_params def create_match_params
params.slice(:team_ids).permit(team_ids: []) params.slice(:team_ids).permit(team_ids: [])
end end

View File

@ -1,27 +1,21 @@
# frozen_string_literal: true # frozen_string_literal: true
class TournamentMatchesBroadcast class TournamentMatchesBroadcast
STATES = [nil, 'upcoming', *Match.states.keys].freeze
class << self class << self
def call(tournament_id) def call(tournament_id)
tournament = load_tournament(tournament_id) tournament = load_tournament(tournament_id)
return if tournament.nil? return if tournament.nil?
STATES.each do |state|
TournamentMatchesChannel.broadcast_to( TournamentMatchesChannel.broadcast_to(
stream_target(tournament, state:), stream_target(tournament),
TournamentMatchesBroadcastPayload.build(tournament, state:) { type: 'tournament_matches.changed', tournament_id: tournament.id }
) )
end end
end
def stream_target(tournament, state:) def stream_target(tournament)
"#{tournament.to_gid_param}:matches:#{state.presence || 'all'}" "#{tournament.to_gid_param}:matches"
end end
private
def load_tournament(tournament_id) def load_tournament(tournament_id)
Tournament.includes( Tournament.includes(
stages: [ stages: [

View File

@ -2,12 +2,13 @@
class TournamentMatchesBroadcastPayload class TournamentMatchesBroadcastPayload
class << self class << self
def build(tournament, state: nil) def build(tournament, filters = {}, state: nil)
filters = TournamentMatchesPayload.normalize_filters(filters, state:)
{ {
type: 'tournament_matches.updated', type: 'tournament_matches.updated',
tournament_id: tournament.id, tournament_id: tournament.id,
state:, state: filters[:state],
matches: TournamentMatchesPayload.build(tournament, state:) matches: TournamentMatchesPayload.build(tournament, filters)
} }
end end
end end

View File

@ -2,18 +2,77 @@
class TournamentMatchesPayload class TournamentMatchesPayload
class << self class << self
def build(tournament, state: nil) def build(tournament, filters = {}, state: nil, **filter_keywords)
serialized_matches(matches_for(tournament, state:)) serialized_matches(matches_for(tournament, filters.merge(filter_keywords), state:))
end end
def matches_for(tournament, state: nil) def matches_for(tournament, filters = {}, state: nil, **filter_keywords)
filters = normalize_filters(filters.merge(filter_keywords), state:)
matches = state_filtered_matches(tournament, filters[:state])
matches.select { |match| match_matches_filters?(match, filters) }
end
def normalize_filters(filters = {}, state: nil)
raw_filters = filters.to_h.with_indifferent_access
raw_filters[:state] = state unless state.nil?
{
state: raw_filters[:state].presence,
stage_id: integer_filter(raw_filters[:stage_id]),
stage_level: integer_filter(raw_filters[:stage_level]),
position: integer_filter(raw_filters[:position]),
group_stage_position: integer_filter(raw_filters[:group_stage_position])
}.compact
end
private
def state_filtered_matches(tournament, state)
return tournament.matches if state.blank? return tournament.matches if state.blank?
return upcoming_matches_for(tournament) if state == 'upcoming' return upcoming_matches_for(tournament) if state == 'upcoming'
tournament.matches.select { |match| match.state == state } tournament.matches.select { |match| match.state == state }
end end
private def match_matches_filters?(match, filters)
stage = match_stage(match)
stage_id_matches?(stage, filters) &&
stage_level_matches?(stage, filters) &&
position_matches?(match, filters) &&
group_stage_position_matches?(match, stage, filters)
end
def match_stage(match)
match.stage || match.group&.stage
end
def stage_id_matches?(stage, filters)
filters[:stage_id].nil? || stage&.id == filters[:stage_id]
end
def stage_level_matches?(stage, filters)
filters[:stage_level].nil? || stage&.level == filters[:stage_level]
end
def position_matches?(match, filters)
filters[:position].nil? || match.position == filters[:position]
end
def group_stage_position_matches?(match, stage, filters)
filters[:group_stage_position].nil? ||
(match.group_match? && stage&.level == -1 && match.position == filters[:group_stage_position])
end
def integer_filter(value)
return nil if value.blank?
return value if value.is_a?(Integer)
Integer(value, 10)
rescue ArgumentError, TypeError
:invalid
end
def upcoming_matches_for(tournament) def upcoming_matches_for(tournament)
upcoming_group_matches(tournament).presence || upcoming_playoff_matches(tournament) upcoming_group_matches(tournament).presence || upcoming_playoff_matches(tournament)

View File

@ -61,11 +61,15 @@ module TurniereE2E
end end
end end
def subscribe_matches!(tournament_id:, state: nil) def subscribe_matches!(tournament_id:, **filters)
identifier = { identifier = {
channel: 'TournamentMatchesChannel', channel: 'TournamentMatchesChannel',
tournament_id:, tournament_id:,
state: state: filters[:state],
stage_id: filters[:stage_id],
stage_level: filters[:stage_level],
position: filters[:position],
group_stage_position: filters[:group_stage_position]
}.compact.to_json }.compact.to_json
subscribe!(identifier:) do |message| subscribe!(identifier:) do |message|

View File

@ -50,6 +50,27 @@ RSpec.describe MatchesController, type: :controller do
expect(match.keys).to match_array(%i[id position state teams match_scores group stage]) expect(match.keys).to match_array(%i[id position state teams match_scores group stage])
expect(match[:teams]).to all(include(:id, :name)) expect(match[:teams]).to all(include(:id, :name))
end end
it 'filters group stage matches by position across groups' do
get :index, params: { tournament_id: @tournament.to_param, group_stage_position: 0 }
body = deserialize_response(response)
expect(body).not_to be_empty
expect(body).to all(include(position: 0))
expect(body.map { |match| match.dig(:stage, :level) }.uniq).to eq([-1])
end
end
context 'on a playoff tournament' do
it 'filters matches by stage level' do
stage = @tournament.stages.find_by!(level: 1)
get :index, params: { tournament_id: @tournament.to_param, stage_level: stage.level }
body = deserialize_response(response)
expect(body.map { |match| match.dig(:stage, :level) }.uniq).to eq([stage.level])
expect(body.map { |match| match[:id] }).to match_array(stage.matches.map(&:id))
end
end end
# TODO add test for upcoming once there is test data for a "valid" group stage # TODO add test for upcoming once there is test data for a "valid" group stage
end end

View File

@ -203,6 +203,14 @@ RSpec.describe 'Backend API surface HTTP E2E' do
matches_all = anonymous_client.get("/tournaments/#{tournament.fetch(:id)}/matches") matches_all = anonymous_client.get("/tournaments/#{tournament.fetch(:id)}/matches")
matches_not_started = anonymous_client.get("/tournaments/#{tournament.fetch(:id)}/matches", params: { state: 'not_started' }) matches_not_started = anonymous_client.get("/tournaments/#{tournament.fetch(:id)}/matches", params: { state: 'not_started' })
matches_upcoming = anonymous_client.get("/tournaments/#{tournament.fetch(:id)}/matches", params: { state: 'upcoming' }) matches_upcoming = anonymous_client.get("/tournaments/#{tournament.fetch(:id)}/matches", params: { state: 'upcoming' })
matches_group_position = anonymous_client.get(
"/tournaments/#{tournament.fetch(:id)}/matches",
params: { group_stage_position: match.fetch(:position) }
)
matches_group_stage = anonymous_client.get(
"/tournaments/#{tournament.fetch(:id)}/matches",
params: { stage_level: group_stage.fetch(:level) }
)
match_show = anonymous_client.get("/matches/#{match.fetch(:id)}") match_show = anonymous_client.get("/matches/#{match.fetch(:id)}")
invalid_match_update = owner_client.patch("/matches/#{match.fetch(:id)}", body: { state: 'bogus' }) invalid_match_update = owner_client.patch("/matches/#{match.fetch(:id)}", body: { state: 'bogus' })
forbidden_match_update = other_client.patch("/matches/#{match.fetch(:id)}", body: { state: 'in_progress' }) forbidden_match_update = other_client.patch("/matches/#{match.fetch(:id)}", body: { state: 'in_progress' })
@ -271,6 +279,12 @@ RSpec.describe 'Backend API surface HTTP E2E' do
expect(matches_not_started[:json].map { |item| item[:id] }).to include(match.fetch(:id)) expect(matches_not_started[:json].map { |item| item[:id] }).to include(match.fetch(:id))
expect(matches_upcoming[:status]).to eq(200) expect(matches_upcoming[:status]).to eq(200)
expect(matches_upcoming[:json].map { |item| item[:id] }).to include(match.fetch(:id)) expect(matches_upcoming[:json].map { |item| item[:id] }).to include(match.fetch(:id))
expect(matches_group_position[:status]).to eq(200)
expect(matches_group_position[:json].map { |item| item.dig(:stage, :level) }.uniq).to eq([-1])
expect(matches_group_position[:json]).to all(include(position: match.fetch(:position)))
expect(matches_group_stage[:status]).to eq(200)
expect(matches_group_stage[:json].count).to eq(tournament_match_count(tournament))
expect(matches_group_stage[:json].map { |item| item.dig(:stage, :id) }.uniq).to eq([group_stage.fetch(:id)])
expect(match_show[:status]).to eq(200) expect(match_show[:status]).to eq(200)
expect(invalid_match_update[:status]).to eq(422) expect(invalid_match_update[:status]).to eq(422)
expect(invalid_match_update.dig(:json, :error)).to eq('Invalid target state') expect(invalid_match_update.dig(:json, :error)).to eq('Invalid target state')

View File

@ -29,8 +29,20 @@ RSpec.describe 'Tournament live websocket HTTP E2E' do
tournament_cable = TurniereE2E::ActionCableClient.new(base_url:) tournament_cable = TurniereE2E::ActionCableClient.new(base_url:)
matches_cable = TurniereE2E::ActionCableClient.new(base_url:) matches_cable = TurniereE2E::ActionCableClient.new(base_url:)
filtered_matches_cable = TurniereE2E::ActionCableClient.new(base_url:)
stage_matches_cable = TurniereE2E::ActionCableClient.new(base_url:)
group_position_filter = { group_stage_position: match.fetch(:position) }
stage_filter = { stage_level: group_stage.fetch(:level) }
tournament_cable.subscribe_tournament!(tournament_id: tournament.fetch(:id)) tournament_cable.subscribe_tournament!(tournament_id: tournament.fetch(:id))
matches_cable.subscribe_matches!(tournament_id: tournament.fetch(:id)) matches_cable.subscribe_matches!(tournament_id: tournament.fetch(:id))
filtered_matches_cable.subscribe_matches!(
tournament_id: tournament.fetch(:id),
**group_position_filter
)
stage_matches_cable.subscribe_matches!(
tournament_id: tournament.fetch(:id),
**stage_filter
)
expect(tournament_cable.wait_for_tournament_payload!.fetch('tournament')).to( expect(tournament_cable.wait_for_tournament_payload!.fetch('tournament')).to(
eq(json_for_api(fetch_tournament(tournament.fetch(:id)))) eq(json_for_api(fetch_tournament(tournament.fetch(:id))))
@ -38,6 +50,12 @@ RSpec.describe 'Tournament live websocket HTTP E2E' do
expect(matches_cable.wait_for_matches_payload!.fetch('matches')).to( expect(matches_cable.wait_for_matches_payload!.fetch('matches')).to(
eq(json_for_api(fetch_matches(tournament.fetch(:id)))) eq(json_for_api(fetch_matches(tournament.fetch(:id))))
) )
expect(filtered_matches_cable.wait_for_matches_payload!.fetch('matches')).to(
eq(json_for_api(fetch_matches(tournament.fetch(:id), params: group_position_filter)))
)
expect(stage_matches_cable.wait_for_matches_payload!.fetch('matches')).to(
eq(json_for_api(fetch_matches(tournament.fetch(:id), params: stage_filter)))
)
play_match!(match.fetch(:id), first_score: 3, second_score: 1) play_match!(match.fetch(:id), first_score: 3, second_score: 1)
@ -55,9 +73,29 @@ RSpec.describe 'Tournament live websocket HTTP E2E' do
matches_payload = wait_for_matches_payload!(matches_cable, match_id: match.fetch(:id), state: 'finished') matches_payload = wait_for_matches_payload!(matches_cable, match_id: match.fetch(:id), state: 'finished')
expect(matches_payload.fetch('matches')).to eq(json_for_api(fetch_matches(tournament.fetch(:id)))) expect(matches_payload.fetch('matches')).to eq(json_for_api(fetch_matches(tournament.fetch(:id))))
filtered_matches_payload = wait_for_matches_payload!(
filtered_matches_cable,
match_id: match.fetch(:id),
state: 'finished'
)
expect(filtered_matches_payload.fetch('matches')).to(
eq(json_for_api(fetch_matches(tournament.fetch(:id), params: group_position_filter)))
)
stage_matches_payload = wait_for_matches_payload!(
stage_matches_cable,
match_id: match.fetch(:id),
state: 'finished'
)
expect(stage_matches_payload.fetch('matches')).to(
eq(json_for_api(fetch_matches(tournament.fetch(:id), params: stage_filter)))
)
ensure ensure
tournament_cable&.close tournament_cable&.close
matches_cable&.close matches_cable&.close
filtered_matches_cable&.close
stage_matches_cable&.close
end end
def login_client def login_client
@ -111,8 +149,8 @@ RSpec.describe 'Tournament live websocket HTTP E2E' do
response.fetch(:json) response.fetch(:json)
end end
def fetch_matches(tournament_id) def fetch_matches(tournament_id, params: {})
response = TurniereE2E::ApiClient.new(base_url:).get("/tournaments/#{tournament_id}/matches") response = TurniereE2E::ApiClient.new(base_url:).get("/tournaments/#{tournament_id}/matches", params:)
expect(response[:status]).to eq(200) expect(response[:status]).to eq(200)
response.fetch(:json) response.fetch(:json)
end end

View File

@ -34,6 +34,29 @@ RSpec.describe 'Tournament live payloads' do
expect(serialized_match[:teams].map { |team| team[:id] }).to match_array(match.teams.map(&:id)) expect(serialized_match[:teams].map { |team| team[:id] }).to match_array(match.teams.map(&:id))
expect(serialized_match[:match_scores].map { |score| score[:team][:id] }).to match_array(match.teams.map(&:id)) expect(serialized_match[:match_scores].map { |score| score[:team][:id] }).to match_array(match.teams.map(&:id))
end end
it 'filters group stage matches by position across groups' do
tournament = create(:group_stage_tournament, group_count: 2, match_factory: :filled_group_match)
tournament.group_stage.groups.each do |group|
group.matches.each_with_index { |match, index| match.update!(position: index) }
end
matches = described_class.matches_for(tournament, group_stage_position: 0)
expect(matches).to all(be_group_match)
expect(matches.map(&:position).uniq).to eq([0])
expect(matches.map { |match| match.group.stage.level }.uniq).to eq([-1])
expect(matches.map(&:group_id)).to match_array(tournament.group_stage.groups.map(&:id))
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)
matches = described_class.matches_for(tournament, stage_level: stage.level)
expect(matches).to match_array(stage.matches)
end
end end
describe TournamentBroadcastPayload do describe TournamentBroadcastPayload do