diff --git a/AGENTS.md b/AGENTS.md index 469f657..54d96fc 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -163,6 +163,23 @@ That means: - new commonly needed backend states should be added to the scenario/task layer, not recreated ad hoc in each consuming test suite - the released production image should be exercised through the blackbox task layer, not only Rails-internal test entrypoints +## Websocket Live Updates + +Team-action-list live updates use Rails ActionCable at `/cable`. + +Practical rule: + +- if a backend feature needs live frontend updates, prefer one shared broadcast path for all mutation sources +- do not duplicate separate "HTTP update logic" and "websocket update logic" +- model/service callbacks should fan out websocket broadcasts so normal writes and follower-sync imports both trigger same live update behavior + +For team action lists specifically: + +- subscribe by tournament id +- broadcast full current list snapshot for that tournament +- keep write APIs idempotent and item-scoped +- cover both direct-app websocket updates and follower-sync websocket propagation in E2E + ## Practical Expectation If you touch test, verification, boot, Docker, or scenario setup workflows, diff --git a/app/channels/application_cable/channel.rb b/app/channels/application_cable/channel.rb new file mode 100644 index 0000000..9aec230 --- /dev/null +++ b/app/channels/application_cable/channel.rb @@ -0,0 +1,6 @@ +# frozen_string_literal: true + +module ApplicationCable + class Channel < ActionCable::Channel::Base + end +end diff --git a/app/channels/application_cable/connection.rb b/app/channels/application_cable/connection.rb new file mode 100644 index 0000000..8d6c2a1 --- /dev/null +++ b/app/channels/application_cable/connection.rb @@ -0,0 +1,6 @@ +# frozen_string_literal: true + +module ApplicationCable + class Connection < ActionCable::Connection::Base + end +end diff --git a/app/channels/tournament_team_action_lists_channel.rb b/app/channels/tournament_team_action_lists_channel.rb new file mode 100644 index 0000000..763d370 --- /dev/null +++ b/app/channels/tournament_team_action_lists_channel.rb @@ -0,0 +1,9 @@ +# frozen_string_literal: true + +class TournamentTeamActionListsChannel < ApplicationCable::Channel + def subscribed + tournament = Tournament.find(params[:tournament_id]) + stream_for tournament + transmit TeamActionListBroadcastPayload.build(tournament) + end +end diff --git a/app/models/team_action_item.rb b/app/models/team_action_item.rb index 9724f59..c3e2228 100644 --- a/app/models/team_action_item.rb +++ b/app/models/team_action_item.rb @@ -9,10 +9,20 @@ class TeamActionItem < ApplicationRecord delegate :tournament, :owner, to: :team_action_list before_validation :normalize_completed_at + before_destroy :store_tournament_id_for_broadcast + after_commit :broadcast_state_change, on: %i[create update destroy] private def normalize_completed_at self.completed_at = completed? ? (completed_at || Time.current) : nil end + + def store_tournament_id_for_broadcast + @broadcast_tournament_id = team_action_list.tournament_id + end + + def broadcast_state_change + TeamActionListBroadcast.call(@broadcast_tournament_id || team_action_list.tournament_id) + end end diff --git a/app/models/team_action_list.rb b/app/models/team_action_list.rb index cd68cc9..1cbedcf 100644 --- a/app/models/team_action_list.rb +++ b/app/models/team_action_list.rb @@ -9,6 +9,9 @@ class TeamActionList < ApplicationRecord delegate :owner, to: :tournament + before_destroy :store_tournament_id_for_broadcast + after_commit :broadcast_state_change, on: %i[create update destroy] + def sync_teams!(teams) desired_team_ids = teams.map(&:id).uniq @@ -20,4 +23,14 @@ class TeamActionList < ApplicationRecord end end end + + private + + def store_tournament_id_for_broadcast + @broadcast_tournament_id = tournament_id + end + + def broadcast_state_change + TeamActionListBroadcast.call(@broadcast_tournament_id || tournament_id) + end end diff --git a/app/services/team_action_list_broadcast.rb b/app/services/team_action_list_broadcast.rb new file mode 100644 index 0000000..56d19ed --- /dev/null +++ b/app/services/team_action_list_broadcast.rb @@ -0,0 +1,15 @@ +# frozen_string_literal: true + +class TeamActionListBroadcast + class << self + def call(tournament_id) + tournament = Tournament.includes(team_action_lists: { team_action_items: :team }).find_by(id: tournament_id) + return if tournament.nil? + + TournamentTeamActionListsChannel.broadcast_to( + tournament, + TeamActionListBroadcastPayload.build(tournament) + ) + end + end +end diff --git a/app/services/team_action_list_broadcast_payload.rb b/app/services/team_action_list_broadcast_payload.rb new file mode 100644 index 0000000..f508240 --- /dev/null +++ b/app/services/team_action_list_broadcast_payload.rb @@ -0,0 +1,22 @@ +# frozen_string_literal: true + +class TeamActionListBroadcastPayload + class << self + def build(tournament) + { + type: 'team_action_lists.updated', + tournament_id: tournament.id, + team_action_lists: serialized_lists(tournament) + } + end + + private + + def serialized_lists(tournament) + ActiveModelSerializers::SerializableResource.new( + tournament.team_action_lists.includes(team_action_items: :team).order(:id), + each_serializer: TeamActionListSerializer + ).as_json + end + end +end diff --git a/config/application.rb b/config/application.rb index ba2e151..bd16c7a 100644 --- a/config/application.rb +++ b/config/application.rb @@ -9,9 +9,9 @@ require 'active_job/railtie' require 'active_record/railtie' require 'active_storage/engine' require 'action_controller/railtie' +require 'action_cable/engine' require 'action_mailer/railtie' require 'action_view/railtie' -# require "action_cable/engine" # require "sprockets/railtie" require 'rails/test_unit/railtie' @@ -34,6 +34,8 @@ module TurniereBackend # Middleware like session, flash, cookies can be added back manually. # Skip views, helpers and assets when generating a new resource. config.api_only = true + config.action_cable.disable_request_forgery_protection = true + config.action_cable.allowed_request_origins = [%r{\Ahttps?://.*\z}] config.middleware.insert_before 0, Rack::Cors do allow do diff --git a/config/cable.yml b/config/cable.yml new file mode 100644 index 0000000..1b9f7a4 --- /dev/null +++ b/config/cable.yml @@ -0,0 +1,8 @@ +development: + adapter: async + +test: + adapter: async + +production: + adapter: async diff --git a/config/routes.rb b/config/routes.rb index e4fac80..75de6ab 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -1,6 +1,8 @@ # frozen_string_literal: true Rails.application.routes.draw do + mount ActionCable.server => '/cable' + mount_devise_token_auth_for 'User', at: 'users', controllers: { registrations: 'overrides/registrations', sessions: 'overrides/sessions' diff --git a/doc/team_action_lists_api.md b/doc/team_action_lists_api.md index 24a29d6..4ec66fa 100644 --- a/doc/team_action_lists_api.md +++ b/doc/team_action_lists_api.md @@ -160,6 +160,71 @@ That means: - follower tournament receives updated list state - follower users can view but not mutate item state while read-only +## Websocket / Live Update Behavior + +Team action lists also support live updates over ActionCable. + +Endpoint: + +- `GET /cable` for websocket upgrade + +Channel: + +- `TournamentTeamActionListsChannel` + +Subscription identifier example: + +```json +{ + "channel": "TournamentTeamActionListsChannel", + "tournament_id": 123 +} +``` + +Behavior: + +- subscription immediately receives current full team-action-list snapshot for that tournament +- later list/item changes broadcast updated full snapshot again +- same broadcast path is used for: + - direct source updates + - follower sync imports + +Payload shape: + +```json +{ + "type": "team_action_lists.updated", + "tournament_id": 123, + "team_action_lists": [ + { + "id": 9, + "name": "Playoff token collection", + "key": "group_stage_survivor_playoff_tokens", + "action_name": "Collect playoff token", + "source": "group_stage_survivors", + "team_action_items": [ + { + "id": 41, + "completed": true, + "completed_at": "2026-04-23T09:15:00Z", + "team": { + "id": 55, + "name": "Team A" + } + } + ] + } + ] +} +``` + +Frontend guidance: + +- use websocket payload as replacement snapshot for team-action-list state in that tournament +- do not merge by toggling local state blindly +- keep writes item-scoped through HTTP +- use websocket only for read/live propagation + ## Future Extension Guidance When new lists are added, frontend should not hardcode backend internals beyond: diff --git a/e2e/lib/action_cable_client.rb b/e2e/lib/action_cable_client.rb new file mode 100644 index 0000000..b79cadc --- /dev/null +++ b/e2e/lib/action_cable_client.rb @@ -0,0 +1,140 @@ +# frozen_string_literal: true + +require 'json' +require 'socket' +require 'openssl' +require 'uri' +require 'websocket/driver' + +module TurniereE2E + class ActionCableClient + ACTION_CABLE_PROTOCOLS = %w[actioncable-v1-json actioncable-unsupported].freeze + + def initialize(base_url:) + @base_url = base_url.sub(%r{/\z}, '') + @messages = Queue.new + @closed = false + end + + def subscribe_team_action_lists!(tournament_id:) + connect! + identifier = { + channel: 'TournamentTeamActionListsChannel', + tournament_id: tournament_id + }.to_json + + driver.text( + JSON.dump( + command: 'subscribe', + identifier: identifier + ) + ) + + wait_for! do |message| + message['type'] == 'confirm_subscription' || + (message['identifier'] == identifier && message['message'].is_a?(Hash)) + end + end + + def wait_for_payload!(timeout: 20) + wait_for!(timeout: timeout) do |message| + payload = message['message'] + payload.is_a?(Hash) && payload['type'] == 'team_action_lists.updated' + end.fetch('message') + end + + def close + return if @closed + + @closed = true + driver.close + socket.close + rescue IOError, SystemCallError + nil + end + + private + + attr_reader :base_url, :socket, :driver + + def connect! + return unless @driver.nil? + + @socket = build_socket + @driver = WebSocket::Driver.client(self, protocols: ACTION_CABLE_PROTOCOLS) + @driver.set_header('Origin', http_origin) + @driver.on(:message) { |event| @messages << JSON.parse(event.data) } + @driver.start + pump_until_open! + end + + def build_socket + uri = URI.parse(websocket_url) + tcp_socket = TCPSocket.new(uri.host, uri.port) + return tcp_socket unless uri.scheme == 'wss' + + ssl_context = OpenSSL::SSL::SSLContext.new + ssl_socket = OpenSSL::SSL::SSLSocket.new(tcp_socket, ssl_context) + ssl_socket.hostname = uri.host if ssl_socket.respond_to?(:hostname=) + ssl_socket.connect + ssl_socket + end + + def websocket_url + uri = URI.parse(base_url) + uri.scheme = uri.scheme == 'https' ? 'wss' : 'ws' + uri.path = '/cable' + uri.query = nil + uri.to_s + end + + def http_origin + uri = URI.parse(base_url) + "#{uri.scheme}://#{uri.host}:#{uri.port}" + end + + def url + websocket_url + end + + def write(data) + socket.write(data) + end + + def pump_until_open!(timeout: 10) + deadline = Time.now + timeout + while Time.now < deadline + read_from_socket(timeout: 0.5) + return if driver.ready_state == 1 + end + + raise "websocket did not open within #{timeout}s" + end + + def wait_for!(timeout: 20) + deadline = Time.now + timeout + loop do + until @messages.empty? + message = @messages.pop(true) + return message if yield(message) + end + + raise "websocket message not received within #{timeout}s" if Time.now >= deadline + + read_from_socket(timeout: 0.5) + rescue ThreadError + nil + end + end + + def read_from_socket(timeout:) + readable, = IO.select([socket], nil, nil, timeout) + return if readable.nil? + + chunk = socket.readpartial(4096) + driver.parse(chunk) + rescue EOFError + nil + end + end +end diff --git a/spec/e2e/http/tournament_follow_sync_websocket_spec.rb b/spec/e2e/http/tournament_follow_sync_websocket_spec.rb new file mode 100644 index 0000000..15b5e6b --- /dev/null +++ b/spec/e2e/http/tournament_follow_sync_websocket_spec.rb @@ -0,0 +1,173 @@ +# 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 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) } + let(:follower_anonymous_client) { TurniereE2E::ApiClient.new(base_url: follower_base_url) } + + it 'broadcasts follower websocket updates after source sync import' do + source = create_group_stage_tournament(client: source_client, base_url: source_base_url, name_prefix: 'Ws Sync Source') + follower = create_follower_tournament(name_prefix: 'Ws Sync 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) + + finish_group_stage_and_create_playoffs!(source_id: source.fetch(:id)) + follower = wait_for_list_presence!(follower.fetch(:id)) + + cable_client = TurniereE2E::ActionCableClient.new(base_url: follower_base_url) + cable_client.subscribe_team_action_lists!(tournament_id: follower.fetch(:id)) + cable_client.wait_for_payload! + + source_after_playoffs = fetch_tournament(base_url: source_base_url, tournament_id: source.fetch(:id)) + token_item = find_playoff_token_list!(source_after_playoffs).fetch(:team_action_items).first + + update = source_client.patch("/team_action_items/#{token_item.fetch(:id)}", body: { completed: true }) + expect(update[:status]).to eq(200) + + follower_payload = wait_for_team_action_list_state!( + cable_client, + team_name: token_item.dig(:team, :name), + completed: true + ) + expect(team_action_item_completed?(follower_payload, team_name: token_item.dig(:team, :name))).to eq(true) + ensure + cable_client&.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 websocket source tournament', + public: false, + group_stage: true, + playoff_teams_amount: 4, + teams: 2.times.flat_map do |group_index| + 4.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 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 fetch_tournament(base_url:, tournament_id:) + client = TurniereE2E::ApiClient.new(base_url: base_url) + response = client.get("/tournaments/#{tournament_id}") + expect(response[:status]).to eq(200) + response.fetch(:json) + end + + def finish_group_stage_and_create_playoffs!(source_id:) + tournament = fetch_tournament(base_url: source_base_url, tournament_id: source_id) + group_stage = tournament.fetch(:stages).find { |stage| stage.fetch(:level) == -1 } + + group_stage.fetch(:groups).each do |group| + group.fetch(:matches).each_with_index do |match, index| + play_match!(match.fetch(:id), first_score: 10 + index, second_score: index) + end + end + + finish_stage = source_client.patch("/stages/#{group_stage.fetch(:id)}", body: { state: 'finished' }) + expect(finish_stage[:status]).to eq(200) + 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_list_presence!(tournament_id, timeout: 20) + deadline = Time.now + timeout + loop do + follower = fetch_tournament(base_url: follower_base_url, tournament_id: tournament_id) + return follower if follower.fetch(:team_action_lists, []).any? + + raise "follower list not present within #{timeout}s" if Time.now >= deadline + + sleep 0.5 + end + end + + def wait_for_team_action_list_state!(cable_client, team_name:, completed:, timeout: 20) + deadline = Time.now + timeout + loop do + payload = cable_client.wait_for_payload!(timeout: [deadline - Time.now, 0.1].max) + return payload if team_action_item_completed?(payload, team_name: team_name) == completed + + raise "follower websocket payload not received within #{timeout}s" if Time.now >= deadline + end + end + + def find_playoff_token_list!(tournament) + tournament.fetch(:team_action_lists).find do |list| + list.fetch(:key) == 'group_stage_survivor_playoff_tokens' + end || raise('playoff token list missing') + end + + def team_action_item_completed?(payload, team_name:) + list = payload.fetch('team_action_lists').find { |candidate| candidate.fetch('key') == 'group_stage_survivor_playoff_tokens' } + item = list.fetch('team_action_items').find { |candidate| candidate.dig('team', 'name') == team_name } + item.fetch('completed') + end +end diff --git a/spec/e2e/http/tournament_team_action_lists_websocket_spec.rb b/spec/e2e/http/tournament_team_action_lists_websocket_spec.rb new file mode 100644 index 0000000..f5bfda2 --- /dev/null +++ b/spec/e2e/http/tournament_team_action_lists_websocket_spec.rb @@ -0,0 +1,154 @@ +# 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 team action lists 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 } + let(:anonymous_client) { TurniereE2E::ApiClient.new(base_url: base_url) } + + it 'broadcasts initial and updated team action list state over websocket' do + tournament = create_group_stage_tournament(name_prefix: 'Ws Leader') + finish_group_stage_and_create_playoffs!(tournament.fetch(:id)) + tournament = fetch_tournament(tournament.fetch(:id)) + token_list = find_playoff_token_list!(tournament) + + cable_client = TurniereE2E::ActionCableClient.new(base_url: base_url) + cable_client.subscribe_team_action_lists!(tournament_id: tournament.fetch(:id)) + + initial_payload = cable_client.wait_for_payload! + expect(initial_payload.fetch('team_action_lists').first.fetch('key')).to eq('group_stage_survivor_playoff_tokens') + + first_item = token_list.fetch(:team_action_items).first + second_item = token_list.fetch(:team_action_items).second + + update_by_item_id = client.patch("/team_action_items/#{first_item.fetch(:id)}", body: { completed: true }) + expect(update_by_item_id[:status]).to eq(200) + + first_update = wait_for_team_action_list_state!( + cable_client, + team_name: first_item.dig(:team, :name), + completed: true + ) + expect(team_action_item_completed?(first_update, team_name: first_item.dig(:team, :name))).to eq(true) + + update_by_business_key = client.patch( + "/tournaments/#{tournament.fetch(:id)}/team_action_lists/group_stage_survivor_playoff_tokens/teams/#{second_item.dig(:team, :id)}", + body: { completed: true } + ) + expect(update_by_business_key[:status]).to eq(200) + + second_update = wait_for_team_action_list_state!( + cable_client, + team_name: second_item.dig(:team, :name), + completed: true + ) + expect(team_action_item_completed?(second_update, team_name: first_item.dig(:team, :name))).to eq(true) + expect(team_action_item_completed?(second_update, team_name: second_item.dig(:team, :name))).to eq(true) + ensure + cable_client&.close + end + + def login_client + api_client = TurniereE2E::ApiClient.new(base_url: 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: 'Websocket action list tournament', + public: false, + group_stage: true, + playoff_teams_amount: 4, + teams: 2.times.flat_map do |group_index| + 4.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 fetch_tournament(tournament_id) + response = anonymous_client.get("/tournaments/#{tournament_id}") + expect(response[:status]).to eq(200) + response.fetch(:json) + end + + def finish_group_stage_and_create_playoffs!(tournament_id) + tournament = fetch_tournament(tournament_id) + group_stage = tournament.fetch(:stages).find { |stage| stage.fetch(:level) == -1 } + + group_stage.fetch(:groups).each do |group| + group.fetch(:matches).each_with_index do |match, index| + play_match!(match.fetch(:id), first_score: 10 + index, second_score: index) + end + end + + finish_stage = client.patch("/stages/#{group_stage.fetch(:id)}", body: { state: 'finished' }) + expect(finish_stage[:status]).to eq(200) + end + + def play_match!(match_id, first_score:, second_score:) + match_response = anonymous_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 wait_for_team_action_list_state!(cable_client, team_name:, completed:, timeout: 20) + deadline = Time.now + timeout + loop do + payload = cable_client.wait_for_payload!(timeout: [deadline - Time.now, 0.1].max) + return payload if team_action_item_completed?(payload, team_name: team_name) == completed + + raise "team action websocket payload not received within #{timeout}s" if Time.now >= deadline + end + end + + def find_playoff_token_list!(tournament) + tournament.fetch(:team_action_lists).find do |list| + list.fetch(:key) == 'group_stage_survivor_playoff_tokens' + end || raise('playoff token list missing') + end + + def team_action_item_completed?(payload, team_name:) + list = payload.fetch('team_action_lists').find { |candidate| candidate.fetch('key') == 'group_stage_survivor_playoff_tokens' } + item = list.fetch('team_action_items').find { |candidate| candidate.dig('team', 'name') == team_name } + item.fetch('completed') + end +end diff --git a/tasks.py b/tasks.py index 6fd9f3d..284773a 100644 --- a/tasks.py +++ b/tasks.py @@ -414,7 +414,8 @@ def _run_blackbox_rspec(base_url, email, password, username, alt_email=E2E_ALT_E ) if extra_env: env.update(extra_env) - command = _rspec_command(spec_path) + spec_paths = spec_path if isinstance(spec_path, (list, tuple)) else [spec_path] + command = _rspec_command(*spec_paths) print(_format_command(command), flush=True) result = _stream_subprocess(command, env=env, check=False) @@ -1692,7 +1693,10 @@ def blackbox_follow_service( alt_email, alt_password, alt_username, - spec_path="spec/e2e/http/tournament_follow_sync_spec.rb", + spec_path=[ + "spec/e2e/http/tournament_follow_sync_spec.rb", + "spec/e2e/http/tournament_follow_sync_websocket_spec.rb", + ], extra_env={ "TURNIERE_SOURCE_E2E_BASE_URL": source_base_url, "TURNIERE_FOLLOWER_E2E_BASE_URL": follower_base_url,