diff --git a/app/channels/tournament_matches_channel.rb b/app/channels/tournament_matches_channel.rb index 4e2e78f..954a988 100644 --- a/app/channels/tournament_matches_channel.rb +++ b/app/channels/tournament_matches_channel.rb @@ -2,10 +2,28 @@ class TournamentMatchesChannel < ApplicationCable::Channel def subscribed - tournament = Tournament.find(params[:tournament_id]) - state = params[:state].presence + @tournament_id = params[:tournament_id] + @filters = match_filters + tournament = Tournament.find(@tournament_id) - stream_for TournamentMatchesBroadcast.stream_target(tournament, state:) - transmit TournamentMatchesBroadcastPayload.build(tournament, state:) + stream_for TournamentMatchesBroadcast.stream_target(tournament) do + 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 diff --git a/app/controllers/matches_controller.rb b/app/controllers/matches_controller.rb index 929e889..a2cdb01 100644 --- a/app/controllers/matches_controller.rb +++ b/app/controllers/matches_controller.rb @@ -13,7 +13,7 @@ class MatchesController < ApplicationController # GET/tournaments/1/matches def index - render json: TournamentMatchesPayload.build(@tournament, state: match_params['state']) + render json: TournamentMatchesPayload.build(@tournament, match_index_params) end # GET /matches/1 @@ -149,6 +149,10 @@ class MatchesController < ApplicationController params.slice(:state).permit! end + def match_index_params + params.slice(:state, :stage_id, :stage_level, :position, :group_stage_position).permit! + end + def create_match_params params.slice(:team_ids).permit(team_ids: []) end diff --git a/app/services/tournament_matches_broadcast.rb b/app/services/tournament_matches_broadcast.rb index 792ebd3..7c2727b 100644 --- a/app/services/tournament_matches_broadcast.rb +++ b/app/services/tournament_matches_broadcast.rb @@ -1,27 +1,21 @@ # frozen_string_literal: true class TournamentMatchesBroadcast - STATES = [nil, 'upcoming', *Match.states.keys].freeze - class << self def call(tournament_id) tournament = load_tournament(tournament_id) return if tournament.nil? - STATES.each do |state| - TournamentMatchesChannel.broadcast_to( - stream_target(tournament, state:), - TournamentMatchesBroadcastPayload.build(tournament, state:) - ) - end + TournamentMatchesChannel.broadcast_to( + stream_target(tournament), + { type: 'tournament_matches.changed', tournament_id: tournament.id } + ) end - def stream_target(tournament, state:) - "#{tournament.to_gid_param}:matches:#{state.presence || 'all'}" + def stream_target(tournament) + "#{tournament.to_gid_param}:matches" end - private - def load_tournament(tournament_id) Tournament.includes( stages: [ diff --git a/app/services/tournament_matches_broadcast_payload.rb b/app/services/tournament_matches_broadcast_payload.rb index 1e044ed..ebb9d5f 100644 --- a/app/services/tournament_matches_broadcast_payload.rb +++ b/app/services/tournament_matches_broadcast_payload.rb @@ -2,12 +2,13 @@ class TournamentMatchesBroadcastPayload class << self - def build(tournament, state: nil) + def build(tournament, filters = {}, state: nil) + filters = TournamentMatchesPayload.normalize_filters(filters, state:) { type: 'tournament_matches.updated', tournament_id: tournament.id, - state:, - matches: TournamentMatchesPayload.build(tournament, state:) + state: filters[:state], + matches: TournamentMatchesPayload.build(tournament, filters) } end end diff --git a/app/services/tournament_matches_payload.rb b/app/services/tournament_matches_payload.rb index c3e679c..dea06cc 100644 --- a/app/services/tournament_matches_payload.rb +++ b/app/services/tournament_matches_payload.rb @@ -2,18 +2,77 @@ class TournamentMatchesPayload class << self - def build(tournament, state: nil) - serialized_matches(matches_for(tournament, state:)) + def build(tournament, filters = {}, state: nil, **filter_keywords) + serialized_matches(matches_for(tournament, filters.merge(filter_keywords), state:)) 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 upcoming_matches_for(tournament) if state == 'upcoming' tournament.matches.select { |match| match.state == state } 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) upcoming_group_matches(tournament).presence || upcoming_playoff_matches(tournament) diff --git a/e2e/lib/action_cable_client.rb b/e2e/lib/action_cable_client.rb index bc8f21d..47a7be0 100644 --- a/e2e/lib/action_cable_client.rb +++ b/e2e/lib/action_cable_client.rb @@ -61,11 +61,15 @@ module TurniereE2E end end - def subscribe_matches!(tournament_id:, state: nil) + def subscribe_matches!(tournament_id:, **filters) identifier = { channel: 'TournamentMatchesChannel', 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 subscribe!(identifier:) do |message| diff --git a/spec/controllers/matches_controller_spec.rb b/spec/controllers/matches_controller_spec.rb index b7429a4..099f5b0 100644 --- a/spec/controllers/matches_controller_spec.rb +++ b/spec/controllers/matches_controller_spec.rb @@ -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[:teams]).to all(include(:id, :name)) 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 # TODO add test for upcoming once there is test data for a "valid" group stage end diff --git a/spec/e2e/http/api_surface_spec.rb b/spec/e2e/http/api_surface_spec.rb index aebfcaf..a235632 100644 --- a/spec/e2e/http/api_surface_spec.rb +++ b/spec/e2e/http/api_surface_spec.rb @@ -203,6 +203,14 @@ RSpec.describe 'Backend API surface HTTP E2E' do 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_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)}") 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' }) @@ -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_upcoming[:status]).to eq(200) 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(invalid_match_update[:status]).to eq(422) expect(invalid_match_update.dig(:json, :error)).to eq('Invalid target state') diff --git a/spec/e2e/http/tournament_live_websocket_spec.rb b/spec/e2e/http/tournament_live_websocket_spec.rb index bb3fe2a..8f18233 100644 --- a/spec/e2e/http/tournament_live_websocket_spec.rb +++ b/spec/e2e/http/tournament_live_websocket_spec.rb @@ -29,8 +29,20 @@ RSpec.describe 'Tournament live websocket HTTP E2E' do tournament_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)) 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( 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( 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) @@ -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') 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 tournament_cable&.close matches_cable&.close + filtered_matches_cable&.close + stage_matches_cable&.close end def login_client @@ -111,8 +149,8 @@ RSpec.describe 'Tournament live websocket HTTP E2E' do response.fetch(:json) end - def fetch_matches(tournament_id) - response = TurniereE2E::ApiClient.new(base_url:).get("/tournaments/#{tournament_id}/matches") + def fetch_matches(tournament_id, params: {}) + response = TurniereE2E::ApiClient.new(base_url:).get("/tournaments/#{tournament_id}/matches", params:) expect(response[:status]).to eq(200) response.fetch(:json) end diff --git a/spec/services/tournament_live_payload_spec.rb b/spec/services/tournament_live_payload_spec.rb index 3d3502e..cd91ab4 100644 --- a/spec/services/tournament_live_payload_spec.rb +++ b/spec/services/tournament_live_payload_spec.rb @@ -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[:match_scores].map { |score| score[:team][:id] }).to match_array(match.teams.map(&:id)) 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 describe TournamentBroadcastPayload do