diff --git a/app/channels/tournament_channel.rb b/app/channels/tournament_channel.rb new file mode 100644 index 0000000..6084bec --- /dev/null +++ b/app/channels/tournament_channel.rb @@ -0,0 +1,9 @@ +# frozen_string_literal: true + +class TournamentChannel < ApplicationCable::Channel + def subscribed + tournament = Tournament.find(params[:tournament_id]) + stream_for tournament + transmit TournamentBroadcastPayload.build(tournament) + end +end diff --git a/app/channels/tournament_matches_channel.rb b/app/channels/tournament_matches_channel.rb new file mode 100644 index 0000000..c9abf0e --- /dev/null +++ b/app/channels/tournament_matches_channel.rb @@ -0,0 +1,11 @@ +# frozen_string_literal: true + +class TournamentMatchesChannel < ApplicationCable::Channel + def subscribed + tournament = Tournament.find(params[:tournament_id]) + state = params[:state].presence + + stream_for TournamentMatchesBroadcast.stream_target(tournament, state: state) + transmit TournamentMatchesBroadcastPayload.build(tournament, state: state) + end +end diff --git a/app/controllers/match_scores_controller.rb b/app/controllers/match_scores_controller.rb index d239aef..07f55bb 100644 --- a/app/controllers/match_scores_controller.rb +++ b/app/controllers/match_scores_controller.rb @@ -16,6 +16,7 @@ class MatchScoresController < ApplicationController if @match_score.update(match_score_params) UpdateGroupsGroupScoresAndSave.call(group: @match_score.match.group) if @match_score.part_of_group_match? push_sync_if_needed!(@match_score.tournament) + TournamentLiveBroadcast.call(@match_score.tournament.id) render json: @match_score else render json: @match_score.errors, status: :unprocessable_entity diff --git a/app/controllers/matches_controller.rb b/app/controllers/matches_controller.rb index ce43a3d..929e889 100644 --- a/app/controllers/matches_controller.rb +++ b/app/controllers/matches_controller.rb @@ -13,35 +13,7 @@ class MatchesController < ApplicationController # GET/tournaments/1/matches def index - matches = if match_params['state'].nil? - @tournament.matches - - # support for upcoming matches for beamer view - elsif match_params['state'] == 'upcoming' - # for every group within the tournament find the match with the lowest position that is of state 'not_started' - upcoming_matches = @tournament.stages.find_by(level: -1)&.groups&.map { |g| g.matches.select { |m| m.state == 'not_started' }.min_by(&:position) } - # filter out nil values (this may happen if one of the groups already has no upcoming matches) - upcoming_matches = upcoming_matches.reject(&:nil?) - # if there are none, the group stage is over, so we have to look into the playoff stages - if upcoming_matches.nil? - next_level = 0 - @tournament.stages.sort_by(&:level).reverse_each do |stage| - # the following if equates to true if it finds a stage where all matches are of state `in_progress` - if stage.matches.reject { |m| m.state == 'in_progress' }.nil? - next_level = stage.level - 1 - break - end - end - @tournament.stages.find_by(level: next_level).matches - else - upcoming_matches - end - else - @tournament.matches.select do |m| - m.state == match_params['state'] - end - end - render json: matches, each_serializer: ExtendedMatchSerializer, include: %w[match_scores.team bets stage group] + render json: TournamentMatchesPayload.build(@tournament, state: match_params['state']) end # GET /matches/1 @@ -58,6 +30,7 @@ class MatchesController < ApplicationController elsif match.save UpdateGroupsGroupScoresAndSave.call(group: @group) push_sync_if_needed!(@group.tournament) + TournamentLiveBroadcast.call(@group.tournament.id) render json: match, include: %w[match_scores.team group], status: :created else render json: match.errors, status: :unprocessable_entity @@ -87,6 +60,7 @@ class MatchesController < ApplicationController end end push_sync_if_needed!(synced_tournament) unless synced_tournament.nil? + TournamentLiveBroadcast.call(synced_tournament.id) unless synced_tournament.nil? end # PATCH /matches/1/swap @@ -111,6 +85,7 @@ class MatchesController < ApplicationController 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) + TournamentLiveBroadcast.call(synced_tournament.id) end private diff --git a/app/controllers/stages_controller.rb b/app/controllers/stages_controller.rb index fcdac4d..f6bdf0e 100644 --- a/app/controllers/stages_controller.rb +++ b/app/controllers/stages_controller.rb @@ -32,6 +32,7 @@ class StagesController < ApplicationController end end push_sync_if_needed!(synced_tournament) unless synced_tournament.nil? + TournamentLiveBroadcast.call(synced_tournament.id) unless synced_tournament.nil? else render json: { error: 'The state attribute may only be changed to finished' diff --git a/app/controllers/tournaments_controller.rb b/app/controllers/tournaments_controller.rb index 5fe10f0..3625ef7 100644 --- a/app/controllers/tournaments_controller.rb +++ b/app/controllers/tournaments_controller.rb @@ -46,11 +46,7 @@ class TournamentsController < ApplicationController render json: @tournament, serializer: SimpleTournamentSerializer else rendered_json = @request_profiling.measure('serialize_tournament') do - ActiveModelSerializers::SerializableResource.new( - @tournament, - include: '**', - scope: current_user - ).as_json + TournamentPayload.build(@tournament, scope: current_user) end @request_profiling.apply_to(response, label: 'tournament.show') render json: rendered_json @@ -146,6 +142,7 @@ class TournamentsController < ApplicationController def sync_state TournamentSnapshotImporter.import!(tournament: @tournament, snapshot: sync_state_params) + TournamentLiveBroadcast.call(@tournament.id) render json: @tournament, include: '**' rescue KeyError, ArgumentError => e render json: { error: e.message }, status: :unprocessable_entity diff --git a/app/serializers/live_match_serializer.rb b/app/serializers/live_match_serializer.rb new file mode 100644 index 0000000..8932c2e --- /dev/null +++ b/app/serializers/live_match_serializer.rb @@ -0,0 +1,16 @@ +# frozen_string_literal: true + +class LiveMatchSerializer < ApplicationSerializer + attributes :position, :state, :teams + + has_many :match_scores + + def teams + object.teams.map do |team| + { + id: team.id, + name: team.name + } + end + end +end diff --git a/app/services/tournament_broadcast.rb b/app/services/tournament_broadcast.rb new file mode 100644 index 0000000..de80ca4 --- /dev/null +++ b/app/services/tournament_broadcast.rb @@ -0,0 +1,32 @@ +# frozen_string_literal: true + +class TournamentBroadcast + class << self + def call(tournament_id) + tournament = load_tournament(tournament_id) + return if tournament.nil? + + TournamentChannel.broadcast_to( + tournament, + TournamentBroadcastPayload.build(tournament) + ) + end + + private + + def load_tournament(tournament_id) + Tournament.includes( + :user, + :teams, + :team_action_lists, + stages: [ + { matches: { match_scores: :team } }, + { groups: [ + { matches: { match_scores: :team } }, + { group_scores: :team } + ] } + ] + ).find_by(id: tournament_id) + end + end +end diff --git a/app/services/tournament_broadcast_payload.rb b/app/services/tournament_broadcast_payload.rb new file mode 100644 index 0000000..f893d97 --- /dev/null +++ b/app/services/tournament_broadcast_payload.rb @@ -0,0 +1,13 @@ +# frozen_string_literal: true + +class TournamentBroadcastPayload + class << self + def build(tournament) + { + type: 'tournament.updated', + tournament_id: tournament.id, + tournament: TournamentPayload.build(tournament) + } + end + end +end diff --git a/app/services/tournament_live_broadcast.rb b/app/services/tournament_live_broadcast.rb new file mode 100644 index 0000000..ad8f5b9 --- /dev/null +++ b/app/services/tournament_live_broadcast.rb @@ -0,0 +1,10 @@ +# frozen_string_literal: true + +class TournamentLiveBroadcast + class << self + def call(tournament_id) + TournamentBroadcast.call(tournament_id) + TournamentMatchesBroadcast.call(tournament_id) + end + end +end diff --git a/app/services/tournament_matches_broadcast.rb b/app/services/tournament_matches_broadcast.rb new file mode 100644 index 0000000..d3c582e --- /dev/null +++ b/app/services/tournament_matches_broadcast.rb @@ -0,0 +1,37 @@ +# 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: state), + TournamentMatchesBroadcastPayload.build(tournament, state: state) + ) + end + end + + def stream_target(tournament, state:) + "#{tournament.to_gid_param}:matches:#{state.presence || 'all'}" + end + + private + + def load_tournament(tournament_id) + Tournament.includes( + stages: [ + { matches: { match_scores: :team } }, + { groups: [ + { matches: { match_scores: :team } }, + { group_scores: :team } + ] } + ] + ).find_by(id: tournament_id) + end + end +end diff --git a/app/services/tournament_matches_broadcast_payload.rb b/app/services/tournament_matches_broadcast_payload.rb new file mode 100644 index 0000000..70df665 --- /dev/null +++ b/app/services/tournament_matches_broadcast_payload.rb @@ -0,0 +1,14 @@ +# frozen_string_literal: true + +class TournamentMatchesBroadcastPayload + class << self + def build(tournament, state: nil) + { + type: 'tournament_matches.updated', + tournament_id: tournament.id, + state: state, + matches: TournamentMatchesPayload.build(tournament, state: state) + } + end + end +end diff --git a/app/services/tournament_matches_payload.rb b/app/services/tournament_matches_payload.rb new file mode 100644 index 0000000..8fe43ab --- /dev/null +++ b/app/services/tournament_matches_payload.rb @@ -0,0 +1,45 @@ +# frozen_string_literal: true + +class TournamentMatchesPayload + class << self + def build(tournament, state: nil) + serialized_matches(matches_for(tournament, state: state)) + end + + def matches_for(tournament, state: nil) + return tournament.matches if state.blank? + return upcoming_matches_for(tournament) if state == 'upcoming' + + tournament.matches.select { |match| match.state == state } + end + + private + + def upcoming_matches_for(tournament) + group_stage = tournament.stages.find { |stage| stage.level == -1 } + group_matches = group_stage&.groups&.map do |group| + group.matches.select { |match| match.state == 'not_started' }.min_by(&:position) + end&.compact + return group_matches unless group_matches.blank? + + next_level = nil + tournament.stages.reject { |stage| stage.level.nil? }.sort_by(&:level).reverse_each do |stage| + next unless stage.matches.any? + next unless stage.matches.all?(&:in_progress?) + + next_level = stage.level - 1 + break + end + + tournament.stages.find { |stage| stage.level == next_level }&.matches || [] + end + + def serialized_matches(matches) + ActiveModelSerializers::SerializableResource.new( + matches, + each_serializer: LiveMatchSerializer, + include: %w[match_scores.team] + ).as_json + end + end +end diff --git a/app/services/tournament_payload.rb b/app/services/tournament_payload.rb new file mode 100644 index 0000000..7516e85 --- /dev/null +++ b/app/services/tournament_payload.rb @@ -0,0 +1,14 @@ +# frozen_string_literal: true + +class TournamentPayload + class << self + def build(tournament, scope: nil) + ActiveModelSerializers::SerializableResource.new( + tournament, + serializer: TournamentSerializer, + include: '**', + scope: scope + ).as_json + end + end +end diff --git a/e2e/lib/action_cable_client.rb b/e2e/lib/action_cable_client.rb index 0102ced..04f4b22 100644 --- a/e2e/lib/action_cable_client.rb +++ b/e2e/lib/action_cable_client.rb @@ -50,6 +50,29 @@ module TurniereE2E end end + def subscribe_tournament!(tournament_id:) + identifier = { + channel: 'TournamentChannel', + tournament_id: tournament_id + }.to_json + + subscribe!(identifier:) do |message| + tournament_payload_message?(message) + end + end + + def subscribe_matches!(tournament_id:, state: nil) + identifier = { + channel: 'TournamentMatchesChannel', + tournament_id: tournament_id, + state: state + }.compact.to_json + + subscribe!(identifier:) do |message| + matches_payload_message?(message) + end + end + def wait_for_payload!(timeout: 20) wait_for!(timeout: timeout) do |message| team_action_lists_payload_message?(message) @@ -68,6 +91,18 @@ module TurniereE2E end.fetch('message') end + def wait_for_tournament_payload!(timeout: 20) + wait_for!(timeout: timeout) do |message| + tournament_payload_message?(message) + end.fetch('message') + end + + def wait_for_matches_payload!(timeout: 20) + wait_for!(timeout: timeout) do |message| + matches_payload_message?(message) + end.fetch('message') + end + def close return if @closed @@ -194,5 +229,15 @@ module TurniereE2E payload = message['message'] payload.is_a?(Hash) && payload['type'].to_s.start_with?('beamer.') end + + def tournament_payload_message?(message) + payload = message['message'] + payload.is_a?(Hash) && payload['type'] == 'tournament.updated' + end + + def matches_payload_message?(message) + payload = message['message'] + payload.is_a?(Hash) && payload['type'] == 'tournament_matches.updated' + end end end diff --git a/spec/controllers/match_scores_controller_spec.rb b/spec/controllers/match_scores_controller_spec.rb index a108f40..f9cec18 100644 --- a/spec/controllers/match_scores_controller_spec.rb +++ b/spec/controllers/match_scores_controller_spec.rb @@ -37,6 +37,7 @@ RSpec.describe MatchScoresController, type: :controller do end context 'when match_score update succeeds' do it 'updates the requested score' do + expect(TournamentLiveBroadcast).to receive(:call).with(@match_score.tournament.id) put :update, params: { id: @match_score.to_param }.merge(valid_update) @match_score.reload expect(@match_score.points).to eq(valid_update[:points]) diff --git a/spec/controllers/matches_controller_spec.rb b/spec/controllers/matches_controller_spec.rb index 3641514..522d58f 100644 --- a/spec/controllers/matches_controller_spec.rb +++ b/spec/controllers/matches_controller_spec.rb @@ -42,6 +42,14 @@ RSpec.describe MatchesController, type: :controller do body = deserialize_response response expect(body.empty?).to be true end + + it 'returns only live match fields' do + get :index, params: { tournament_id: @tournament.to_param } + match = deserialize_response(response).first + + expect(match.keys).to match_array(%i[id position state teams match_scores]) + expect(match[:teams]).to all(include(:id, :name)) + end end # TODO add test for upcoming once there is test data for a "valid" group stage end @@ -121,6 +129,7 @@ RSpec.describe MatchesController, type: :controller do original_first_position = first_match.position original_last_position = last_match.position + expect(TournamentLiveBroadcast).to receive(:call).with(group_stage_tournament.id) patch :swap, params: { id: first_match.to_param, other_match_id: last_match.to_param } expect(response).to be_successful @@ -189,6 +198,7 @@ RSpec.describe MatchesController, type: :controller do context 'with valid params' do it 'updates the match' do + expect(TournamentLiveBroadcast).to receive(:call).with(@match.tournament.id) put :update, params: { id: @match.to_param }.merge(valid_update) @match.reload expect(response).to be_successful diff --git a/spec/controllers/stages_controller_spec.rb b/spec/controllers/stages_controller_spec.rb index b9e7c2b..9311ea7 100644 --- a/spec/controllers/stages_controller_spec.rb +++ b/spec/controllers/stages_controller_spec.rb @@ -71,6 +71,7 @@ RSpec.describe StagesController, type: :controller do end before do + expect(TournamentLiveBroadcast).to receive(:call).with(running_group_stage.tournament.id) put :update, params: { id: running_group_stage.to_param }.merge(finished) running_group_stage.reload end diff --git a/spec/controllers/tournaments_controller_spec.rb b/spec/controllers/tournaments_controller_spec.rb index 6ba0ed7..9a9cb9a 100644 --- a/spec/controllers/tournaments_controller_spec.rb +++ b/spec/controllers/tournaments_controller_spec.rb @@ -111,6 +111,7 @@ RSpec.describe TournamentsController, type: :controller do expect(json[:name]).to eq(@tournament.name) expect(json[:description]).to eq(@tournament.description) expect(json[:public]).to eq(@tournament.public) + expect(json).to eq(TournamentPayload.build(@tournament)) end it 'does not return sync metadata on unauthenticated requests' do diff --git a/spec/e2e/http/tournament_follow_sync_live_websocket_spec.rb b/spec/e2e/http/tournament_follow_sync_live_websocket_spec.rb new file mode 100644 index 0000000..fa0f5e8 --- /dev/null +++ b/spec/e2e/http/tournament_follow_sync_live_websocket_spec.rb @@ -0,0 +1,181 @@ +# frozen_string_literal: true + +require 'spec_helper' +require 'securerandom' +require_relative '../../../e2e/lib/api_client' +require_relative '../../../e2e/lib/action_cable_client' + +RSpec.describe 'Tournament follower sync live websocket HTTP E2E' do + required_env_vars = %w[ + TURNIERE_E2E_EMAIL + TURNIERE_E2E_PASSWORD + TURNIERE_SOURCE_E2E_BASE_URL + TURNIERE_FOLLOWER_E2E_BASE_URL + ].freeze + + before do + missing_vars = required_env_vars.reject { |name| ENV.key?(name) } + skip("HTTP E2E requires #{missing_vars.join(', ')}") unless missing_vars.empty? + end + + let(:source_base_url) { ENV.fetch('TURNIERE_SOURCE_E2E_BASE_URL') } + let(:follower_base_url) { ENV.fetch('TURNIERE_FOLLOWER_E2E_BASE_URL') } + let(:owner_email) { ENV.fetch('TURNIERE_E2E_EMAIL') } + let(:owner_password) { ENV.fetch('TURNIERE_E2E_PASSWORD') } + let(:sync_token) { "sync-#{SecureRandom.hex(8)}" } + let(:source_client) { login_client(base_url: source_base_url) } + + it 'broadcasts follower tournament and match updates after source match sync import' do + source = create_group_stage_tournament(client: source_client, base_url: source_base_url, name_prefix: 'Ws Live Source') + follower = create_follower_tournament(name_prefix: 'Ws Live Follower') + + configure_sync = source_client.patch("/tournaments/#{source.fetch(:id)}", body: { + sync_target_url: "#{follower_base_url}/tournaments/#{follower.fetch(:id)}/sync_state", + sync_auth_token: sync_token + }) + expect(configure_sync[:status]).to eq(200) + + follower = wait_for_follower_match_count!(follower.fetch(:id), count: source.fetch(:stages).first.fetch(:groups).flat_map { |group| group.fetch(:matches) }.count) + source_match = source.fetch(:stages).first.fetch(:groups).first.fetch(:matches).first + follower_match_id = follower_match_for_source!(follower, source_match).fetch(:id) + + tournament_cable = TurniereE2E::ActionCableClient.new(base_url: follower_base_url) + matches_cable = TurniereE2E::ActionCableClient.new(base_url: follower_base_url) + tournament_cable.subscribe_tournament!(tournament_id: follower.fetch(:id)) + matches_cable.subscribe_matches!(tournament_id: follower.fetch(:id)) + tournament_cable.wait_for_tournament_payload! + matches_cable.wait_for_matches_payload! + + play_match!(match_id: source_match.fetch(:id), first_score: 5, second_score: 2) + + tournament_payload = wait_for_tournament_payload!(tournament_cable, tournament_id: follower.fetch(:id)) do |payload| + payload.dig('tournament', 'stages').flat_map { |stage| stage.fetch('groups', []) } + .flat_map { |group| group.fetch('matches', []) } + .any? { |candidate| candidate.fetch('id') == follower_match_id && candidate.fetch('state') == 'finished' } + end + matches_payload = wait_for_matches_payload!(matches_cable, match_id: follower_match_id, state: 'finished') + + expect(tournament_payload.fetch('tournament')).to eq(json_for_api(fetch_tournament(base_url: follower_base_url, tournament_id: follower.fetch(:id)))) + expect(matches_payload.fetch('matches')).to eq(json_for_api(fetch_matches(base_url: follower_base_url, tournament_id: follower.fetch(:id)))) + ensure + tournament_cable&.close + matches_cable&.close + end + + def login_client(base_url:) + client = TurniereE2E::ApiClient.new(base_url: base_url) + response = client.login!(email: owner_email, password: owner_password) + expect(response[:status]).to eq(200) + client + end + + def create_group_stage_tournament(client:, base_url:, name_prefix:) + response = client.post('/tournaments', body: { + name: "#{name_prefix} #{SecureRandom.hex(3)}", + description: 'Follower live websocket source tournament', + public: false, + group_stage: true, + playoff_teams_amount: 2, + teams: 2.times.flat_map do |group_index| + 2.times.map do |seed_index| + { + name: "#{name_prefix.tr(' ', '')}-G#{group_index + 1}-S#{seed_index + 1}", + group: group_index + } + end + end + }) + expect(response[:status]).to eq(201) + fetch_tournament(base_url: base_url, tournament_id: response.dig(:json, :id)) + end + + def create_follower_tournament(name_prefix:) + client = login_client(base_url: follower_base_url) + response = client.post('/tournaments', body: { + name: "#{name_prefix} #{SecureRandom.hex(3)}", + description: 'Follower live websocket target tournament', + public: true, + read_only_mode: true, + sync_auth_token: sync_token + }) + expect(response[:status]).to eq(201) + fetch_tournament(base_url: follower_base_url, tournament_id: response.dig(:json, :id)) + end + + def play_match!(match_id:, first_score:, second_score:) + match_response = TurniereE2E::ApiClient.new(base_url: source_base_url).get("/matches/#{match_id}") + expect(match_response[:status]).to eq(200) + + start_response = source_client.patch("/matches/#{match_id}", body: { state: 'in_progress' }) + expect(start_response[:status]).to eq(200) + + ordered_scores = match_response.fetch(:json).fetch(:match_scores).sort_by { |score| score.fetch(:id) } + [first_score, second_score].each_with_index do |points, index| + score_update = source_client.patch("/match_scores/#{ordered_scores[index].fetch(:id)}", body: { points: points }) + expect(score_update[:status]).to eq(200) + end + + finish_response = source_client.patch("/matches/#{match_id}", body: { state: 'finished' }) + expect(finish_response[:status]).to eq(200) + end + + def wait_for_follower_match_count!(tournament_id, count:, timeout: 20) + deadline = Time.now + timeout + loop do + follower = fetch_tournament(base_url: follower_base_url, tournament_id: tournament_id) + matches = follower.fetch(:stages).flat_map do |stage| + stage.fetch(:groups, []).flat_map { |group| group.fetch(:matches, []) } + stage.fetch(:matches, []) + end + return follower if matches.count == count + + raise "follower tournament not synced within #{timeout}s" if Time.now >= deadline + + sleep 0.5 + end + end + + def follower_match_for_source!(follower, source_match) + follower.fetch(:stages).flat_map do |stage| + stage.fetch(:groups, []).flat_map { |group| group.fetch(:matches, []) } + stage.fetch(:matches, []) + end.find do |candidate| + candidate.fetch(:position) == source_match.fetch(:position) + end || raise('follower match missing') + end + + def fetch_tournament(base_url:, tournament_id:) + response = TurniereE2E::ApiClient.new(base_url: base_url).get("/tournaments/#{tournament_id}") + expect(response[:status]).to eq(200) + response.fetch(:json) + end + + def fetch_matches(base_url:, tournament_id:) + response = TurniereE2E::ApiClient.new(base_url: base_url).get("/tournaments/#{tournament_id}/matches") + expect(response[:status]).to eq(200) + response.fetch(:json) + end + + def wait_for_tournament_payload!(cable_client, tournament_id:, timeout: 20) + deadline = Time.now + timeout + loop do + payload = cable_client.wait_for_tournament_payload!(timeout: [deadline - Time.now, 0.1].max) + return payload if payload.fetch('tournament_id') == tournament_id && yield(payload) + + raise "follower tournament websocket payload not received within #{timeout}s" if Time.now >= deadline + end + end + + def wait_for_matches_payload!(cable_client, match_id:, state:, timeout: 20) + deadline = Time.now + timeout + loop do + payload = cable_client.wait_for_matches_payload!(timeout: [deadline - Time.now, 0.1].max) + match = payload.fetch('matches').find { |candidate| candidate.fetch('id') == match_id } + return payload if match&.fetch('state') == state + + raise "follower matches websocket payload not received within #{timeout}s" if Time.now >= deadline + end + end + + def json_for_api(value) + JSON.parse(JSON.dump(value)) + end +end diff --git a/spec/e2e/http/tournament_live_websocket_spec.rb b/spec/e2e/http/tournament_live_websocket_spec.rb new file mode 100644 index 0000000..599aa7a --- /dev/null +++ b/spec/e2e/http/tournament_live_websocket_spec.rb @@ -0,0 +1,135 @@ +# frozen_string_literal: true + +require 'spec_helper' +require 'securerandom' +require_relative '../../../e2e/lib/api_client' +require_relative '../../../e2e/lib/action_cable_client' + +RSpec.describe 'Tournament live websocket HTTP E2E' do + required_env_vars = %w[ + TURNIERE_E2E_BASE_URL + TURNIERE_E2E_EMAIL + TURNIERE_E2E_PASSWORD + ].freeze + + before do + missing_vars = required_env_vars.reject { |name| ENV.key?(name) } + skip("HTTP E2E requires #{missing_vars.join(', ')}") unless missing_vars.empty? + end + + let(:base_url) { ENV.fetch('TURNIERE_E2E_BASE_URL') } + let(:owner_email) { ENV.fetch('TURNIERE_E2E_EMAIL') } + let(:owner_password) { ENV.fetch('TURNIERE_E2E_PASSWORD') } + let(:client) { login_client } + + it 'broadcasts tournament and match payloads matching HTTP API payloads' do + tournament = create_group_stage_tournament(name_prefix: 'Ws Live') + group_stage = tournament.fetch(:stages).find { |stage| stage.fetch(:level) == -1 } + match = group_stage.fetch(:groups).first.fetch(:matches).first + + tournament_cable = TurniereE2E::ActionCableClient.new(base_url:) + matches_cable = TurniereE2E::ActionCableClient.new(base_url:) + tournament_cable.subscribe_tournament!(tournament_id: tournament.fetch(:id)) + matches_cable.subscribe_matches!(tournament_id: tournament.fetch(:id)) + + expect(tournament_cable.wait_for_tournament_payload!.fetch('tournament')).to eq(json_for_api(fetch_tournament(tournament.fetch(:id)))) + expect(matches_cable.wait_for_matches_payload!.fetch('matches')).to eq(json_for_api(fetch_matches(tournament.fetch(:id)))) + + play_match!(match.fetch(:id), first_score: 3, second_score: 1) + + tournament_payload = wait_for_tournament_payload!(tournament_cable, tournament_id: tournament.fetch(:id)) do |payload| + payload.dig('tournament', 'stages').flat_map { |stage| stage.fetch('groups', []) } + .flat_map { |group| group.fetch('matches', []) } + .any? { |candidate| candidate.fetch('id') == match.fetch(:id) && candidate.fetch('state') == 'finished' } + end + expect(tournament_payload.fetch('tournament')).to eq(json_for_api(fetch_tournament(tournament.fetch(:id)))) + + 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)))) + ensure + tournament_cable&.close + matches_cable&.close + end + + def login_client + api_client = TurniereE2E::ApiClient.new(base_url:) + response = api_client.login!(email: owner_email, password: owner_password) + + expect(response[:status]).to eq(200) + api_client + end + + def create_group_stage_tournament(name_prefix:) + response = client.post('/tournaments', body: { + name: "#{name_prefix} #{SecureRandom.hex(3)}", + description: 'Tournament live websocket tournament', + public: true, + group_stage: true, + playoff_teams_amount: 2, + teams: 2.times.flat_map do |group_index| + 2.times.map do |seed_index| + { + name: "#{name_prefix.tr(' ', '')}-G#{group_index + 1}-S#{seed_index + 1}", + group: group_index + } + end + end + }) + expect(response[:status]).to eq(201) + fetch_tournament(response.dig(:json, :id)) + end + + def play_match!(match_id, first_score:, second_score:) + match_response = client.get("/matches/#{match_id}") + expect(match_response[:status]).to eq(200) + + start_response = client.patch("/matches/#{match_id}", body: { state: 'in_progress' }) + expect(start_response[:status]).to eq(200) + + ordered_scores = match_response.fetch(:json).fetch(:match_scores).sort_by { |score| score.fetch(:id) } + [first_score, second_score].each_with_index do |points, index| + score_update = client.patch("/match_scores/#{ordered_scores[index].fetch(:id)}", body: { points: points }) + expect(score_update[:status]).to eq(200) + end + + finish_response = client.patch("/matches/#{match_id}", body: { state: 'finished' }) + expect(finish_response[:status]).to eq(200) + end + + def fetch_tournament(tournament_id) + response = TurniereE2E::ApiClient.new(base_url:).get("/tournaments/#{tournament_id}") + expect(response[:status]).to eq(200) + response.fetch(:json) + end + + def fetch_matches(tournament_id) + response = TurniereE2E::ApiClient.new(base_url:).get("/tournaments/#{tournament_id}/matches") + expect(response[:status]).to eq(200) + response.fetch(:json) + end + + def wait_for_tournament_payload!(cable_client, tournament_id:, timeout: 20) + deadline = Time.now + timeout + loop do + payload = cable_client.wait_for_tournament_payload!(timeout: [deadline - Time.now, 0.1].max) + return payload if payload.fetch('tournament_id') == tournament_id && yield(payload) + + raise "tournament websocket payload not received within #{timeout}s" if Time.now >= deadline + end + end + + def wait_for_matches_payload!(cable_client, match_id:, state:, timeout: 20) + deadline = Time.now + timeout + loop do + payload = cable_client.wait_for_matches_payload!(timeout: [deadline - Time.now, 0.1].max) + match = payload.fetch('matches').find { |candidate| candidate.fetch('id') == match_id } + return payload if match&.fetch('state') == state + + raise "matches websocket payload not received within #{timeout}s" if Time.now >= deadline + end + end + + def json_for_api(value) + JSON.parse(JSON.dump(value)) + end +end diff --git a/spec/services/tournament_live_payload_spec.rb b/spec/services/tournament_live_payload_spec.rb new file mode 100644 index 0000000..6e9d681 --- /dev/null +++ b/spec/services/tournament_live_payload_spec.rb @@ -0,0 +1,61 @@ +# frozen_string_literal: true + +require 'rails_helper' + +RSpec.describe 'Tournament live payloads' do + describe TournamentPayload do + it 'matches the public tournament API serializer payload' do + tournament = create(:group_stage_tournament, match_factory: :filled_group_match) + + expect(described_class.build(tournament)).to eq( + ActiveModelSerializers::SerializableResource.new( + tournament, + serializer: TournamentSerializer, + include: '**', + scope: nil + ).as_json + ) + end + end + + describe TournamentMatchesPayload do + it 'serializes only match live fields used by matches API and websocket' do + tournament = create(:group_stage_tournament, match_factory: :filled_group_match) + match = tournament.matches.first + + payload = described_class.build(tournament) + serialized_match = payload.find { |candidate| candidate[:id] == match.id } + + expect(serialized_match.keys).to match_array(%i[id position state teams match_scores]) + expect(serialized_match[:state]).to eq(match.state) + expect(serialized_match[:position]).to eq(match.position) + 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 + end + + describe TournamentBroadcastPayload do + it 'wraps the same tournament payload sent by the API' do + tournament = create(:group_stage_tournament, match_factory: :filled_group_match) + + payload = described_class.build(tournament) + + expect(payload[:type]).to eq('tournament.updated') + expect(payload[:tournament_id]).to eq(tournament.id) + expect(payload[:tournament]).to eq(TournamentPayload.build(tournament)) + end + end + + describe TournamentMatchesBroadcastPayload do + it 'wraps the same matches payload sent by the API' do + tournament = create(:group_stage_tournament, match_factory: :filled_group_match) + + payload = described_class.build(tournament, state: 'not_started') + + expect(payload[:type]).to eq('tournament_matches.updated') + expect(payload[:tournament_id]).to eq(tournament.id) + expect(payload[:state]).to eq('not_started') + expect(payload[:matches]).to eq(TournamentMatchesPayload.build(tournament, state: 'not_started')) + end + end +end diff --git a/tasks.py b/tasks.py index 6c3fd0e..8b8d328 100644 --- a/tasks.py +++ b/tasks.py @@ -1725,6 +1725,7 @@ def blackbox_follow_service( spec_path=[ "spec/e2e/http/tournament_follow_sync_spec.rb", "spec/e2e/http/tournament_follow_sync_websocket_spec.rb", + "spec/e2e/http/tournament_follow_sync_live_websocket_spec.rb", "spec/e2e/http/tournament_timer_follow_sync_websocket_spec.rb", "spec/e2e/http/tournament_follow_sync_beamers_websocket_spec.rb", ],