diff --git a/README.md b/README.md index 049050c..1a42f7f 100644 --- a/README.md +++ b/README.md @@ -32,6 +32,7 @@ Leader/follower tournament sync docs: - [doc/leader_follower.md](doc/leader_follower.md) - [doc/timer_api.md](doc/timer_api.md) - [doc/beamer_live_updates.md](doc/beamer_live_updates.md) +- [doc/tournament_live_updates.md](doc/tournament_live_updates.md) ## Docker [Registry](https://gitlab.com/turniere/turniere-backend/container_registry) 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..4e2e78f --- /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:) + transmit TournamentMatchesBroadcastPayload.build(tournament, 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..3b0f27a --- /dev/null +++ b/app/serializers/live_match_serializer.rb @@ -0,0 +1,36 @@ +# frozen_string_literal: true + +class LiveMatchSerializer < ApplicationSerializer + attributes :position, :state, :teams, :group, :stage + + has_many :match_scores + + def teams + object.teams.map do |team| + { + id: team.id, + name: team.name + } + end + end + + def group + return nil if object.group.nil? + + { + id: object.group.id, + number: object.group.number + } + end + + def stage + stage = object.stage || object.group&.stage + return nil if stage.nil? + + { + id: stage.id, + level: stage.level, + state: stage.state + } + 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..792ebd3 --- /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:), + TournamentMatchesBroadcastPayload.build(tournament, 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..1e044ed --- /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:, + matches: TournamentMatchesPayload.build(tournament, 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..c3e679c --- /dev/null +++ b/app/services/tournament_matches_payload.rb @@ -0,0 +1,56 @@ +# frozen_string_literal: true + +class TournamentMatchesPayload + class << self + def build(tournament, state: nil) + serialized_matches(matches_for(tournament, 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) + upcoming_group_matches(tournament).presence || upcoming_playoff_matches(tournament) + end + + def upcoming_group_matches(tournament) + group_stage = tournament.stages.find { |stage| stage.level == -1 } + return [] if group_stage.nil? + + group_stage.groups.filter_map do |group| + group.matches.select { |match| match.state == 'not_started' }.min_by(&:position) + end + end + + def upcoming_playoff_matches(tournament) + active_stage = playoff_stages(tournament).reverse.find do |stage| + stage_with_running_matches?(stage) + end + return [] if active_stage.nil? + + tournament.stages.find { |stage| stage.level == active_stage.level - 1 }&.matches || [] + end + + def stage_with_running_matches?(stage) + stage.matches.any? && stage.matches.all?(&:in_progress?) + end + + def playoff_stages(tournament) + tournament.stages.reject { |stage| stage.level.nil? }.sort_by(&:level) + 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..4e6cf2d --- /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: + ).as_json + end + end +end diff --git a/doc/tournament_live_updates.md b/doc/tournament_live_updates.md new file mode 100644 index 0000000..9d8f9f4 --- /dev/null +++ b/doc/tournament_live_updates.md @@ -0,0 +1,297 @@ +# Tournament Live Updates + +Tournament and match live updates use Rails ActionCable at `/cable`. + +Use these channels for website pages and beamer pages that need automatic updates when: + +- match scores change +- match state changes +- match positions change +- group scores change +- stages change +- playoff matches are populated +- follower-sync imports update a read-only follower tournament + +This is websocket push, not webhook callback. + +## Transport + +- websocket endpoint: `GET /cable` +- protocol: ActionCable +- no auth token is required for read subscriptions +- write operations still go through HTTP APIs + +Frontend should: + +1. Load initial state with HTTP. +2. Subscribe to relevant ActionCable channel. +3. Treat incoming websocket payloads as replacement snapshots. +4. Continue sending mutations through HTTP only. + +## Channels + +### TournamentChannel + +Use this when page needs full tournament structure: + +- stages +- groups +- group scores +- group matches +- playoff stages +- playoff matches +- teams and advancing flags +- timer fields +- team action lists + +Subscription identifier: + +```json +{ + "channel": "TournamentChannel", + "tournament_id": 123 +} +``` + +Payload: + +```json +{ + "type": "tournament.updated", + "tournament_id": 123, + "tournament": { + "id": 123, + "name": "Summer Cup", + "code": "abc123", + "public": true, + "description": "Example tournament", + "playoff_teams_amount": 4, + "instant_finalists_amount": 4, + "intermediate_round_participants_amount": 0, + "timestamp": null, + "timer_mode": null, + "owner_username": "owner", + "stages": [], + "teams": [], + "team_action_lists": [] + } +} +``` + +The nested `tournament` object has same public shape as: + +- `GET /tournaments/:id` + +Frontend can replace cached tournament state with `payload.tournament`. + +### TournamentMatchesChannel + +Use this when page only needs match cards/list updates: + +- match id +- position +- state +- group identity +- stage identity +- teams +- score rows + +Subscription identifier for all matches: + +```json +{ + "channel": "TournamentMatchesChannel", + "tournament_id": 123 +} +``` + +Optional state filter: + +```json +{ + "channel": "TournamentMatchesChannel", + "tournament_id": 123, + "state": "in_progress" +} +``` + +Supported `state` values: + +- omitted / `null`: all matches +- `upcoming` +- `single_team` +- `not_ready` +- `not_started` +- `in_progress` +- `finished` +- `undecided` + +Payload: + +```json +{ + "type": "tournament_matches.updated", + "tournament_id": 123, + "state": null, + "matches": [ + { + "id": 456, + "position": 0, + "state": "in_progress", + "group": { + "id": 44, + "number": 1 + }, + "stage": { + "id": 33, + "level": -1, + "state": "in_progress" + }, + "teams": [ + { + "id": 10, + "name": "Team A" + }, + { + "id": 11, + "name": "Team B" + } + ], + "match_scores": [ + { + "id": 900, + "points": 12, + "hidden_points": 0, + "team": { + "id": 10, + "name": "Team A" + } + }, + { + "id": 901, + "points": 8, + "hidden_points": 0, + "team": { + "id": 11, + "name": "Team B" + } + } + ] + } + ] +} +``` + +The `matches` array has same shape as: + +- `GET /tournaments/:id/matches` +- `GET /tournaments/:id/matches?state=:state` + +Frontend can replace cached match list for that filter with `payload.matches`. + +## Initial Snapshot + +Both channels transmit current snapshot immediately after subscription. + +This means frontend can safely: + +- fetch over HTTP first, then subscribe +- or subscribe and use first websocket message as live refresh + +HTTP first is still recommended because it gives normal request/error/loading behavior. + +## Broadcast Sources + +Broadcasts happen after successful backend writes/imports. + +Covered direct HTTP mutations: + +- `PATCH /match_scores/:id` +- `PATCH /matches/:id` +- `PATCH /matches/:id/swap` +- `POST /groups/:group_id/matches` +- `PATCH /stages/:id` + +Covered follower-sync mutation: + +- `PATCH /tournaments/:id/sync_state` + +Practical effects: + +- score update broadcasts updated matches and updated group scores +- finishing group stage broadcasts new playoff stages/matches +- playoff match finish broadcasts populated next match +- follower tournament subscribers get same updates after source sync import + +## Frontend Integration + +Recommended tournament page flow: + +1. Fetch `GET /tournaments/:id`. +2. Render tournament. +3. Subscribe to `TournamentChannel`. +4. On `tournament.updated`, replace tournament state with `payload.tournament`. +5. Keep writes as HTTP requests. + +Recommended match-list/beamer flow: + +1. Fetch `GET /tournaments/:id/matches` or state-filtered matches endpoint. +2. Render matches. +3. Subscribe to `TournamentMatchesChannel` with same `state` filter. +4. On `tournament_matches.updated`, replace that match list with `payload.matches`. +5. For multiple tabs/lists, use one subscription per filter you need. + +Recommended update handling: + +- do not patch a single nested field from websocket payload +- replace the full snapshot for that channel/filter +- use `match.id`, `group.id`, `stage.id`, `team.id`, and `match_score.id` as stable keys +- tolerate duplicate payloads +- tolerate fast sequences like score update followed by match finish + +## ActionCable Message Example + +Raw subscribe message: + +```json +{ + "command": "subscribe", + "identifier": "{\"channel\":\"TournamentMatchesChannel\",\"tournament_id\":123,\"state\":\"in_progress\"}" +} +``` + +ActionCable wraps server payloads under `message`. +Client libraries usually unwrap this for you. + +Raw received frame shape: + +```json +{ + "identifier": "{\"channel\":\"TournamentMatchesChannel\",\"tournament_id\":123,\"state\":\"in_progress\"}", + "message": { + "type": "tournament_matches.updated", + "tournament_id": 123, + "state": "in_progress", + "matches": [] + } +} +``` + +## Relationship To Other Live Channels + +Other specialized channels still exist: + +- `TournamentTimersChannel`: timer-only payloads +- `TournamentBeamersChannel`: beamer display config payloads +- `TournamentTeamActionListsChannel`: team action list payloads + +Use `TournamentChannel` when full tournament state is needed. +Use specialized channels when frontend wants smaller, focused payloads. + +## Tests + +Covered by: + +- [spec/services/tournament_live_payload_spec.rb](../spec/services/tournament_live_payload_spec.rb) +- [spec/e2e/http/tournament_live_websocket_spec.rb](../spec/e2e/http/tournament_live_websocket_spec.rb) +- [spec/e2e/http/tournament_follow_sync_live_websocket_spec.rb](../spec/e2e/http/tournament_follow_sync_live_websocket_spec.rb) diff --git a/docker-compose.blackbox-follow.yml b/docker-compose.blackbox-follow.yml index 525d8c6..cfb49d9 100644 --- a/docker-compose.blackbox-follow.yml +++ b/docker-compose.blackbox-follow.yml @@ -93,6 +93,8 @@ services: TURNIERE_E2E_ALT_PASSWORD: ${TURNIERE_E2E_ALT_PASSWORD:-password123} TURNIERE_E2E_ALT_USERNAME: ${TURNIERE_E2E_ALT_USERNAME:-e2e-alt-user} networks: + - source-db + - follower-db - sync volumes: diff --git a/e2e/lib/action_cable_client.rb b/e2e/lib/action_cable_client.rb index 0102ced..bc8f21d 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: + }.to_json + + subscribe!(identifier:) do |message| + tournament_payload_message?(message) + end + end + + def subscribe_matches!(tournament_id:, state: nil) + identifier = { + channel: 'TournamentMatchesChannel', + tournament_id:, + 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..b7429a4 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 group stage]) + 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..472ea29 --- /dev/null +++ b/spec/e2e/http/tournament_follow_sync_live_websocket_spec.rb @@ -0,0 +1,211 @@ +# 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_sync_url(follower), + sync_auth_token: sync_token + }) + expect(configure_sync[:status]).to eq(200) + + follower = wait_for_follower_match_count!( + follower.fetch(:id), + count: source_group_matches(source).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? do |candidate| + candidate.fetch('id') == follower_match_id && candidate.fetch('state') == 'finished' + end + 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:) + 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| + Array.new(2) 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:, 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: }) + 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 = monotonic_time + timeout + loop do + follower = fetch_tournament(base_url: follower_base_url, 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 monotonic_time >= deadline + + sleep 0.5 + end + end + + def follower_match_for_source!(follower, source_match) + follower_matches = follower.fetch(:stages).flat_map do |stage| + stage.fetch(:groups, []).flat_map { |group| group.fetch(:matches, []) } + stage.fetch(:matches, []) + end + + follower_matches.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:).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:).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 = monotonic_time + timeout + loop do + payload = cable_client.wait_for_tournament_payload!(timeout: [deadline - monotonic_time, 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 monotonic_time >= deadline + end + end + + def wait_for_matches_payload!(cable_client, match_id:, state:, timeout: 20) + deadline = monotonic_time + timeout + loop do + payload = cable_client.wait_for_matches_payload!(timeout: [deadline - monotonic_time, 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 monotonic_time >= deadline + end + end + + def follower_sync_url(follower) + "#{follower_base_url}/tournaments/#{follower.fetch(:id)}/sync_state" + end + + def source_group_matches(source) + source.fetch(:stages).first.fetch(:groups).flat_map { |group| group.fetch(:matches) } + end + + def monotonic_time + Process.clock_gettime(Process::CLOCK_MONOTONIC) + 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..bb3fe2a --- /dev/null +++ b/spec/e2e/http/tournament_live_websocket_spec.rb @@ -0,0 +1,148 @@ +# 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? do |candidate| + candidate.fetch('id') == match.fetch(:id) && candidate.fetch('state') == 'finished' + end + 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| + Array.new(2) 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: }) + 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 = monotonic_time + timeout + loop do + payload = cable_client.wait_for_tournament_payload!(timeout: [deadline - monotonic_time, 0.1].max) + return payload if payload.fetch('tournament_id') == tournament_id && yield(payload) + + raise "tournament websocket payload not received within #{timeout}s" if monotonic_time >= deadline + end + end + + def wait_for_matches_payload!(cable_client, match_id:, state:, timeout: 20) + deadline = monotonic_time + timeout + loop do + payload = cable_client.wait_for_matches_payload!(timeout: [deadline - monotonic_time, 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 monotonic_time >= deadline + end + end + + def monotonic_time + Process.clock_gettime(Process::CLOCK_MONOTONIC) + 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..3d3502e --- /dev/null +++ b/spec/services/tournament_live_payload_spec.rb @@ -0,0 +1,63 @@ +# 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.stages.find { |stage| stage.level == -1 }.groups.first.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 group stage]) + expect(serialized_match[:state]).to eq(match.state) + expect(serialized_match[:position]).to eq(match.position) + expect(serialized_match.dig(:group, :id)).to eq(match.group_id) + expect(serialized_match.dig(:stage, :id)).to eq(match.group.stage_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)) + 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", ],