From bab28adb159978cda7786d96476349f37d6553b1 Mon Sep 17 00:00:00 2001 From: Malaber Date: Sun, 26 Apr 2026 15:36:15 +0200 Subject: [PATCH 1/7] Add version endpoint --- Dockerfile | 6 ++++++ app/controllers/version_controller.rb | 19 +++++++++++++++++++ config/routes.rb | 1 + spec/e2e/http/api_surface_spec.rb | 7 +++++++ tasks.py | 23 ++++++++++++++++++++++- 5 files changed, 55 insertions(+), 1 deletion(-) create mode 100644 app/controllers/version_controller.rb diff --git a/Dockerfile b/Dockerfile index 9fb91e7..e23ba69 100644 --- a/Dockerfile +++ b/Dockerfile @@ -54,7 +54,10 @@ RUN bundle config set with 'development test' \ FROM runtime-base AS production +ARG GIT_COMMIT_SHA=unknown + ENV RAILS_ENV=production +ENV GIT_COMMIT_SHA=${GIT_COMMIT_SHA} COPY --from=bundle-production /usr/local/bundle /usr/local/bundle COPY --from=bundle-production /app/vendor/bundle /app/vendor/bundle @@ -75,7 +78,10 @@ CMD ["sh", "-lc", "bundle exec rails db:migrate && bundle exec rails s -p 3000 - FROM runtime-base AS test +ARG GIT_COMMIT_SHA=unknown + ENV RAILS_ENV=test +ENV GIT_COMMIT_SHA=${GIT_COMMIT_SHA} RUN apt-get update -qq \ && apt-get install -y --no-install-recommends \ diff --git a/app/controllers/version_controller.rb b/app/controllers/version_controller.rb new file mode 100644 index 0000000..83c7bdc --- /dev/null +++ b/app/controllers/version_controller.rb @@ -0,0 +1,19 @@ +# frozen_string_literal: true + +class VersionController < ApplicationController + def show + render json: { git_commit_sha: } + end + + private + + def git_commit_sha + ENV['GIT_COMMIT_SHA'].presence || local_git_commit_sha || 'unknown' + end + + def local_git_commit_sha + Rails.root.join('.git').exist? ? `git rev-parse HEAD`.strip.presence : nil + rescue StandardError + nil + end +end diff --git a/config/routes.rb b/config/routes.rb index 7ef1030..945366d 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -39,4 +39,5 @@ Rails.application.routes.draw do end resources :match_scores, only: %i[show update] get 'healthz', to: 'health#index' + get 'version', to: 'version#show' end diff --git a/spec/e2e/http/api_surface_spec.rb b/spec/e2e/http/api_surface_spec.rb index aebfcaf..8c92998 100644 --- a/spec/e2e/http/api_surface_spec.rb +++ b/spec/e2e/http/api_surface_spec.rb @@ -29,6 +29,13 @@ RSpec.describe 'Backend API surface HTTP E2E' do expect(response.dig(:json, :errors)).to eq([]) end + it 'reports running version' do + response = anonymous_client.get('/version') + + expect(response[:status]).to eq(200) + expect(response.dig(:json, :git_commit_sha)).to match(/\A[0-9a-f]{40}\z/) + end + it 'supports registration for new users and still requires confirmation for login' do unique = unique_suffix email = "http-e2e-#{unique}@example.com" diff --git a/tasks.py b/tasks.py index a1b8ac3..1145fdc 100644 --- a/tasks.py +++ b/tasks.py @@ -235,6 +235,13 @@ def _format_command(command): return " ".join(shlex.quote(part) for part in command) +def _git_commit_sha(): + try: + return subprocess.check_output(["git", "rev-parse", "HEAD"], text=True).strip() + except subprocess.CalledProcessError: + return "unknown" + + def _docker_build_command(tag, target, platforms=None, push=False): if platforms and "," in str(platforms) and not push: raise ValueError("multi-platform buildx builds require push=True") @@ -246,6 +253,8 @@ def _docker_build_command(tag, target, platforms=None, push=False): "build", "--target", target, + "--build-arg", + f"GIT_COMMIT_SHA={_git_commit_sha()}", "-t", tag, "-f", @@ -257,7 +266,19 @@ def _docker_build_command(tag, target, platforms=None, push=False): command.append(".") return _format_command(command) - return f"docker build --target {target} -t {tag} -f {DOCKERFILE_PATH} ." + return _format_command([ + "docker", + "build", + "--target", + target, + "--build-arg", + f"GIT_COMMIT_SHA={_git_commit_sha()}", + "-t", + tag, + "-f", + DOCKERFILE_PATH, + ".", + ]) def _rspec_command(*paths): From 0ccce16849df8b96ab8aa003e23505da6dfe932a Mon Sep 17 00:00:00 2001 From: Malaber Date: Sun, 26 Apr 2026 16:03:55 +0200 Subject: [PATCH 2/7] feat: add match endpoint filters --- app/channels/tournament_matches_channel.rb | 26 +++++-- app/controllers/matches_controller.rb | 6 +- app/services/tournament_matches_broadcast.rb | 18 ++--- .../tournament_matches_broadcast_payload.rb | 7 +- app/services/tournament_matches_payload.rb | 67 +++++++++++++++++-- e2e/lib/action_cable_client.rb | 8 ++- spec/controllers/matches_controller_spec.rb | 21 ++++++ spec/e2e/http/api_surface_spec.rb | 14 ++++ .../http/tournament_live_websocket_spec.rb | 42 +++++++++++- spec/services/tournament_live_payload_spec.rb | 23 +++++++ 10 files changed, 204 insertions(+), 28 deletions(-) diff --git a/app/channels/tournament_matches_channel.rb b/app/channels/tournament_matches_channel.rb index 4e2e78f..954a988 100644 --- a/app/channels/tournament_matches_channel.rb +++ b/app/channels/tournament_matches_channel.rb @@ -2,10 +2,28 @@ class TournamentMatchesChannel < ApplicationCable::Channel def subscribed - tournament = Tournament.find(params[:tournament_id]) - state = params[:state].presence + @tournament_id = params[:tournament_id] + @filters = match_filters + tournament = Tournament.find(@tournament_id) - stream_for TournamentMatchesBroadcast.stream_target(tournament, state:) - transmit TournamentMatchesBroadcastPayload.build(tournament, state:) + stream_for TournamentMatchesBroadcast.stream_target(tournament) do + transmit_current_payload + end + transmit TournamentMatchesBroadcastPayload.build(tournament, @filters) + end + + private + + attr_reader :tournament_id, :filters + + def match_filters + params.slice(:state, :stage_id, :stage_level, :position, :group_stage_position) + end + + def transmit_current_payload + tournament = TournamentMatchesBroadcast.load_tournament(tournament_id) + return if tournament.nil? + + transmit TournamentMatchesBroadcastPayload.build(tournament, filters) end end diff --git a/app/controllers/matches_controller.rb b/app/controllers/matches_controller.rb index 929e889..a2cdb01 100644 --- a/app/controllers/matches_controller.rb +++ b/app/controllers/matches_controller.rb @@ -13,7 +13,7 @@ class MatchesController < ApplicationController # GET/tournaments/1/matches def index - render json: TournamentMatchesPayload.build(@tournament, state: match_params['state']) + render json: TournamentMatchesPayload.build(@tournament, match_index_params) end # GET /matches/1 @@ -149,6 +149,10 @@ class MatchesController < ApplicationController params.slice(:state).permit! end + def match_index_params + params.slice(:state, :stage_id, :stage_level, :position, :group_stage_position).permit! + end + def create_match_params params.slice(:team_ids).permit(team_ids: []) end diff --git a/app/services/tournament_matches_broadcast.rb b/app/services/tournament_matches_broadcast.rb index 792ebd3..7c2727b 100644 --- a/app/services/tournament_matches_broadcast.rb +++ b/app/services/tournament_matches_broadcast.rb @@ -1,27 +1,21 @@ # frozen_string_literal: true class TournamentMatchesBroadcast - STATES = [nil, 'upcoming', *Match.states.keys].freeze - class << self def call(tournament_id) tournament = load_tournament(tournament_id) return if tournament.nil? - STATES.each do |state| - TournamentMatchesChannel.broadcast_to( - stream_target(tournament, state:), - TournamentMatchesBroadcastPayload.build(tournament, state:) - ) - end + TournamentMatchesChannel.broadcast_to( + stream_target(tournament), + { type: 'tournament_matches.changed', tournament_id: tournament.id } + ) end - def stream_target(tournament, state:) - "#{tournament.to_gid_param}:matches:#{state.presence || 'all'}" + def stream_target(tournament) + "#{tournament.to_gid_param}:matches" end - private - def load_tournament(tournament_id) Tournament.includes( stages: [ diff --git a/app/services/tournament_matches_broadcast_payload.rb b/app/services/tournament_matches_broadcast_payload.rb index 1e044ed..ebb9d5f 100644 --- a/app/services/tournament_matches_broadcast_payload.rb +++ b/app/services/tournament_matches_broadcast_payload.rb @@ -2,12 +2,13 @@ class TournamentMatchesBroadcastPayload class << self - def build(tournament, state: nil) + def build(tournament, filters = {}, state: nil) + filters = TournamentMatchesPayload.normalize_filters(filters, state:) { type: 'tournament_matches.updated', tournament_id: tournament.id, - state:, - matches: TournamentMatchesPayload.build(tournament, state:) + state: filters[:state], + matches: TournamentMatchesPayload.build(tournament, filters) } end end diff --git a/app/services/tournament_matches_payload.rb b/app/services/tournament_matches_payload.rb index c3e679c..dea06cc 100644 --- a/app/services/tournament_matches_payload.rb +++ b/app/services/tournament_matches_payload.rb @@ -2,18 +2,77 @@ class TournamentMatchesPayload class << self - def build(tournament, state: nil) - serialized_matches(matches_for(tournament, state:)) + def build(tournament, filters = {}, state: nil, **filter_keywords) + serialized_matches(matches_for(tournament, filters.merge(filter_keywords), state:)) end - def matches_for(tournament, state: nil) + def matches_for(tournament, filters = {}, state: nil, **filter_keywords) + filters = normalize_filters(filters.merge(filter_keywords), state:) + matches = state_filtered_matches(tournament, filters[:state]) + + matches.select { |match| match_matches_filters?(match, filters) } + end + + def normalize_filters(filters = {}, state: nil) + raw_filters = filters.to_h.with_indifferent_access + raw_filters[:state] = state unless state.nil? + + { + state: raw_filters[:state].presence, + stage_id: integer_filter(raw_filters[:stage_id]), + stage_level: integer_filter(raw_filters[:stage_level]), + position: integer_filter(raw_filters[:position]), + group_stage_position: integer_filter(raw_filters[:group_stage_position]) + }.compact + end + + private + + def state_filtered_matches(tournament, state) return tournament.matches if state.blank? return upcoming_matches_for(tournament) if state == 'upcoming' tournament.matches.select { |match| match.state == state } end - private + def match_matches_filters?(match, filters) + stage = match_stage(match) + + stage_id_matches?(stage, filters) && + stage_level_matches?(stage, filters) && + position_matches?(match, filters) && + group_stage_position_matches?(match, stage, filters) + end + + def match_stage(match) + match.stage || match.group&.stage + end + + def stage_id_matches?(stage, filters) + filters[:stage_id].nil? || stage&.id == filters[:stage_id] + end + + def stage_level_matches?(stage, filters) + filters[:stage_level].nil? || stage&.level == filters[:stage_level] + end + + def position_matches?(match, filters) + filters[:position].nil? || match.position == filters[:position] + end + + def group_stage_position_matches?(match, stage, filters) + filters[:group_stage_position].nil? || + (match.group_match? && stage&.level == -1 && match.position == filters[:group_stage_position]) + end + + def integer_filter(value) + return nil if value.blank? + return value if value.is_a?(Integer) + + Integer(value, 10) + rescue ArgumentError, TypeError + :invalid + end def upcoming_matches_for(tournament) upcoming_group_matches(tournament).presence || upcoming_playoff_matches(tournament) diff --git a/e2e/lib/action_cable_client.rb b/e2e/lib/action_cable_client.rb index bc8f21d..47a7be0 100644 --- a/e2e/lib/action_cable_client.rb +++ b/e2e/lib/action_cable_client.rb @@ -61,11 +61,15 @@ module TurniereE2E end end - def subscribe_matches!(tournament_id:, state: nil) + def subscribe_matches!(tournament_id:, **filters) identifier = { channel: 'TournamentMatchesChannel', tournament_id:, - state: + state: filters[:state], + stage_id: filters[:stage_id], + stage_level: filters[:stage_level], + position: filters[:position], + group_stage_position: filters[:group_stage_position] }.compact.to_json subscribe!(identifier:) do |message| diff --git a/spec/controllers/matches_controller_spec.rb b/spec/controllers/matches_controller_spec.rb index b7429a4..099f5b0 100644 --- a/spec/controllers/matches_controller_spec.rb +++ b/spec/controllers/matches_controller_spec.rb @@ -50,6 +50,27 @@ RSpec.describe MatchesController, type: :controller do expect(match.keys).to match_array(%i[id position state teams match_scores group stage]) expect(match[:teams]).to all(include(:id, :name)) end + + it 'filters group stage matches by position across groups' do + get :index, params: { tournament_id: @tournament.to_param, group_stage_position: 0 } + body = deserialize_response(response) + + expect(body).not_to be_empty + expect(body).to all(include(position: 0)) + expect(body.map { |match| match.dig(:stage, :level) }.uniq).to eq([-1]) + end + end + + context 'on a playoff tournament' do + it 'filters matches by stage level' do + stage = @tournament.stages.find_by!(level: 1) + + get :index, params: { tournament_id: @tournament.to_param, stage_level: stage.level } + body = deserialize_response(response) + + expect(body.map { |match| match.dig(:stage, :level) }.uniq).to eq([stage.level]) + expect(body.map { |match| match[:id] }).to match_array(stage.matches.map(&:id)) + end end # TODO add test for upcoming once there is test data for a "valid" group stage end diff --git a/spec/e2e/http/api_surface_spec.rb b/spec/e2e/http/api_surface_spec.rb index aebfcaf..a235632 100644 --- a/spec/e2e/http/api_surface_spec.rb +++ b/spec/e2e/http/api_surface_spec.rb @@ -203,6 +203,14 @@ RSpec.describe 'Backend API surface HTTP E2E' do matches_all = anonymous_client.get("/tournaments/#{tournament.fetch(:id)}/matches") matches_not_started = anonymous_client.get("/tournaments/#{tournament.fetch(:id)}/matches", params: { state: 'not_started' }) matches_upcoming = anonymous_client.get("/tournaments/#{tournament.fetch(:id)}/matches", params: { state: 'upcoming' }) + matches_group_position = anonymous_client.get( + "/tournaments/#{tournament.fetch(:id)}/matches", + params: { group_stage_position: match.fetch(:position) } + ) + matches_group_stage = anonymous_client.get( + "/tournaments/#{tournament.fetch(:id)}/matches", + params: { stage_level: group_stage.fetch(:level) } + ) match_show = anonymous_client.get("/matches/#{match.fetch(:id)}") invalid_match_update = owner_client.patch("/matches/#{match.fetch(:id)}", body: { state: 'bogus' }) forbidden_match_update = other_client.patch("/matches/#{match.fetch(:id)}", body: { state: 'in_progress' }) @@ -271,6 +279,12 @@ RSpec.describe 'Backend API surface HTTP E2E' do expect(matches_not_started[:json].map { |item| item[:id] }).to include(match.fetch(:id)) expect(matches_upcoming[:status]).to eq(200) expect(matches_upcoming[:json].map { |item| item[:id] }).to include(match.fetch(:id)) + expect(matches_group_position[:status]).to eq(200) + expect(matches_group_position[:json].map { |item| item.dig(:stage, :level) }.uniq).to eq([-1]) + expect(matches_group_position[:json]).to all(include(position: match.fetch(:position))) + expect(matches_group_stage[:status]).to eq(200) + expect(matches_group_stage[:json].count).to eq(tournament_match_count(tournament)) + expect(matches_group_stage[:json].map { |item| item.dig(:stage, :id) }.uniq).to eq([group_stage.fetch(:id)]) expect(match_show[:status]).to eq(200) expect(invalid_match_update[:status]).to eq(422) expect(invalid_match_update.dig(:json, :error)).to eq('Invalid target state') diff --git a/spec/e2e/http/tournament_live_websocket_spec.rb b/spec/e2e/http/tournament_live_websocket_spec.rb index bb3fe2a..8f18233 100644 --- a/spec/e2e/http/tournament_live_websocket_spec.rb +++ b/spec/e2e/http/tournament_live_websocket_spec.rb @@ -29,8 +29,20 @@ RSpec.describe 'Tournament live websocket HTTP E2E' do tournament_cable = TurniereE2E::ActionCableClient.new(base_url:) matches_cable = TurniereE2E::ActionCableClient.new(base_url:) + filtered_matches_cable = TurniereE2E::ActionCableClient.new(base_url:) + stage_matches_cable = TurniereE2E::ActionCableClient.new(base_url:) + group_position_filter = { group_stage_position: match.fetch(:position) } + stage_filter = { stage_level: group_stage.fetch(:level) } tournament_cable.subscribe_tournament!(tournament_id: tournament.fetch(:id)) matches_cable.subscribe_matches!(tournament_id: tournament.fetch(:id)) + filtered_matches_cable.subscribe_matches!( + tournament_id: tournament.fetch(:id), + **group_position_filter + ) + stage_matches_cable.subscribe_matches!( + tournament_id: tournament.fetch(:id), + **stage_filter + ) expect(tournament_cable.wait_for_tournament_payload!.fetch('tournament')).to( eq(json_for_api(fetch_tournament(tournament.fetch(:id)))) @@ -38,6 +50,12 @@ RSpec.describe 'Tournament live websocket HTTP E2E' do expect(matches_cable.wait_for_matches_payload!.fetch('matches')).to( eq(json_for_api(fetch_matches(tournament.fetch(:id)))) ) + expect(filtered_matches_cable.wait_for_matches_payload!.fetch('matches')).to( + eq(json_for_api(fetch_matches(tournament.fetch(:id), params: group_position_filter))) + ) + expect(stage_matches_cable.wait_for_matches_payload!.fetch('matches')).to( + eq(json_for_api(fetch_matches(tournament.fetch(:id), params: stage_filter))) + ) play_match!(match.fetch(:id), first_score: 3, second_score: 1) @@ -55,9 +73,29 @@ RSpec.describe 'Tournament live websocket HTTP E2E' do matches_payload = wait_for_matches_payload!(matches_cable, match_id: match.fetch(:id), state: 'finished') expect(matches_payload.fetch('matches')).to eq(json_for_api(fetch_matches(tournament.fetch(:id)))) + + filtered_matches_payload = wait_for_matches_payload!( + filtered_matches_cable, + match_id: match.fetch(:id), + state: 'finished' + ) + expect(filtered_matches_payload.fetch('matches')).to( + eq(json_for_api(fetch_matches(tournament.fetch(:id), params: group_position_filter))) + ) + + stage_matches_payload = wait_for_matches_payload!( + stage_matches_cable, + match_id: match.fetch(:id), + state: 'finished' + ) + expect(stage_matches_payload.fetch('matches')).to( + eq(json_for_api(fetch_matches(tournament.fetch(:id), params: stage_filter))) + ) ensure tournament_cable&.close matches_cable&.close + filtered_matches_cable&.close + stage_matches_cable&.close end def login_client @@ -111,8 +149,8 @@ RSpec.describe 'Tournament live websocket HTTP E2E' do response.fetch(:json) end - def fetch_matches(tournament_id) - response = TurniereE2E::ApiClient.new(base_url:).get("/tournaments/#{tournament_id}/matches") + def fetch_matches(tournament_id, params: {}) + response = TurniereE2E::ApiClient.new(base_url:).get("/tournaments/#{tournament_id}/matches", params:) expect(response[:status]).to eq(200) response.fetch(:json) end diff --git a/spec/services/tournament_live_payload_spec.rb b/spec/services/tournament_live_payload_spec.rb index 3d3502e..cd91ab4 100644 --- a/spec/services/tournament_live_payload_spec.rb +++ b/spec/services/tournament_live_payload_spec.rb @@ -34,6 +34,29 @@ RSpec.describe 'Tournament live payloads' do expect(serialized_match[:teams].map { |team| team[:id] }).to match_array(match.teams.map(&:id)) expect(serialized_match[:match_scores].map { |score| score[:team][:id] }).to match_array(match.teams.map(&:id)) end + + it 'filters group stage matches by position across groups' do + tournament = create(:group_stage_tournament, group_count: 2, match_factory: :filled_group_match) + tournament.group_stage.groups.each do |group| + group.matches.each_with_index { |match, index| match.update!(position: index) } + end + + matches = described_class.matches_for(tournament, group_stage_position: 0) + + expect(matches).to all(be_group_match) + expect(matches.map(&:position).uniq).to eq([0]) + expect(matches.map { |match| match.group.stage.level }.uniq).to eq([-1]) + expect(matches.map(&:group_id)).to match_array(tournament.group_stage.groups.map(&:id)) + end + + it 'filters all matches belonging to a stage level' do + tournament = create(:stage_tournament, stage_count: 2) + stage = tournament.stages.find_by!(level: 1) + + matches = described_class.matches_for(tournament, stage_level: stage.level) + + expect(matches).to match_array(stage.matches) + end end describe TournamentBroadcastPayload do From acdef9f5b43b86191593822820c442a1005cee6f Mon Sep 17 00:00:00 2001 From: Malaber Date: Sun, 26 Apr 2026 16:13:12 +0200 Subject: [PATCH 3/7] feat(beamers): replace asset URLs with config Add beamer config JSON, drop obsolete asset URL fields, and narrow display_state to the new beamer API states. Migration maps legacy display states to supported values and initializes config to {}. --- app/controllers/beamers_controller.rb | 7 +++- app/models/beamer.rb | 12 +++++-- app/models/tournament.rb | 2 +- app/serializers/beamer_serializer.rb | 4 ++- app/services/beamer_broadcast_payload.rb | 10 +++--- app/services/tournament_snapshot_importer.rb | 3 +- app/services/tournament_sync_schema.rb | 2 +- ...replace_beamer_asset_fields_with_config.rb | 28 ++++++++++++++++ db/schema.rb | 7 ++-- doc/beamer_live_updates.md | 7 ++-- spec/controllers/beamers_controller_spec.rb | 26 +++++++++------ .../http/tournament_beamers_websocket_spec.rb | 24 ++++++++------ ...ment_follow_sync_beamers_websocket_spec.rb | 32 +++++++++++++++---- spec/factories/beamers.rb | 3 +- spec/models/beamer_spec.rb | 22 ++++++++++--- .../tournament_snapshot_importer_spec.rb | 19 +++++------ 16 files changed, 147 insertions(+), 61 deletions(-) create mode 100644 db/migrate/20260426120000_replace_beamer_asset_fields_with_config.rb diff --git a/app/controllers/beamers_controller.rb b/app/controllers/beamers_controller.rb index 74df102..5772eec 100644 --- a/app/controllers/beamers_controller.rb +++ b/app/controllers/beamers_controller.rb @@ -68,7 +68,12 @@ class BeamersController < ApplicationController end def beamer_params - params.permit(:name, :display_state, :qr_code_url, :logo_url) + permitted = params.permit(:name, :display_state).to_h + config = params.key?(:config) ? params[:config] : params.dig(:beamer, :config) + return permitted unless config + + permitted[:config] = config.respond_to?(:to_unsafe_h) ? config.to_unsafe_h : config + permitted end def push_sync_if_needed! diff --git a/app/models/beamer.rb b/app/models/beamer.rb index 47f44e0..3170cf0 100644 --- a/app/models/beamer.rb +++ b/app/models/beamer.rb @@ -3,14 +3,14 @@ class Beamer < ApplicationRecord DEFAULT_NAME = 'default' DISPLAY_STATES = %w[ - matches_all matches_in_progress matches_upcoming matches_not_started - matches_finished groups between_games during_game + during_game between_games static_image group_tables ].freeze belongs_to :tournament validates :name, presence: true, uniqueness: { scope: :tournament_id } validates :display_state, presence: true, inclusion: { in: DISPLAY_STATES } + validate :config_is_hash delegate :owner, to: :tournament @@ -34,7 +34,7 @@ class Beamer < ApplicationRecord BeamerBroadcast.call( tournament_id: snapshot.fetch(:tournament_id), beamer_names: [@broadcast_previous_name, snapshot.fetch(:name)].compact.uniq, - payload: BeamerBroadcastPayload.build(snapshot: snapshot, event: broadcast_event) + payload: BeamerBroadcastPayload.build(snapshot:, event: broadcast_event) ) ensure @broadcast_previous_name = nil @@ -46,4 +46,10 @@ class Beamer < ApplicationRecord previous_changes.key?('id') ? 'created' : 'updated' end + + def config_is_hash + return if config.is_a?(Hash) + + errors.add(:config, 'must be an object') + end end diff --git a/app/models/tournament.rb b/app/models/tournament.rb index 65c22d0..b91176a 100644 --- a/app/models/tournament.rb +++ b/app/models/tournament.rb @@ -76,7 +76,7 @@ class Tournament < ApplicationRecord def ensure_default_beamer! beamers.find_or_create_by!(name: Beamer::DEFAULT_NAME) do |beamer| - beamer.display_state = 'matches_all' + beamer.display_state = 'during_game' end end diff --git a/app/serializers/beamer_serializer.rb b/app/serializers/beamer_serializer.rb index d6863f1..765ad11 100644 --- a/app/serializers/beamer_serializer.rb +++ b/app/serializers/beamer_serializer.rb @@ -1,5 +1,7 @@ # frozen_string_literal: true class BeamerSerializer < ApplicationSerializer - attributes :id, :name, :display_state, :qr_code_url, :logo_url + attributes :id, :name, :display_state, :config + + delegate :config, to: :object end diff --git a/app/services/beamer_broadcast_payload.rb b/app/services/beamer_broadcast_payload.rb index cc47b80..7472ffa 100644 --- a/app/services/beamer_broadcast_payload.rb +++ b/app/services/beamer_broadcast_payload.rb @@ -2,12 +2,12 @@ class BeamerBroadcastPayload class << self - def build(beamer: nil, snapshot: nil, event:) + def build(event:, beamer: nil, snapshot: nil) data = snapshot || self.snapshot(beamer) { type: "beamer.#{event}", - event: event, + event:, tournament_id: data.fetch(:tournament_id), beamer: serialized_beamer(data) } @@ -19,8 +19,7 @@ class BeamerBroadcastPayload tournament_id: beamer.tournament_id, name: beamer.name, display_state: beamer.display_state, - qr_code_url: beamer.qr_code_url, - logo_url: beamer.logo_url + config: beamer.config } end @@ -31,8 +30,7 @@ class BeamerBroadcastPayload id: snapshot.fetch(:id), name: snapshot.fetch(:name), display_state: snapshot.fetch(:display_state), - qr_code_url: snapshot[:qr_code_url], - logo_url: snapshot[:logo_url] + config: snapshot.fetch(:config) } end end diff --git a/app/services/tournament_snapshot_importer.rb b/app/services/tournament_snapshot_importer.rb index d03beba..af298c2 100644 --- a/app/services/tournament_snapshot_importer.rb +++ b/app/services/tournament_snapshot_importer.rb @@ -109,8 +109,7 @@ class TournamentSnapshotImporter beamer.name = attrs.fetch(:name) beamer.display_state = attrs.fetch(:display_state) beamer.is_default = attrs.fetch(:is_default, false) - beamer.qr_code_url = attrs[:qr_code_url] - beamer.logo_url = attrs[:logo_url] + beamer.config = attrs.fetch(:config, {}) beamer.save! beamers_by_source_id[source_id] = beamer end diff --git a/app/services/tournament_sync_schema.rb b/app/services/tournament_sync_schema.rb index 3d85a2d..64b4d33 100644 --- a/app/services/tournament_sync_schema.rb +++ b/app/services/tournament_sync_schema.rb @@ -33,7 +33,7 @@ class TournamentSyncSchema ignored: %w[tournament_id created_at updated_at sync_source_id].freeze }.freeze, Beamer => { - synced: %w[id name display_state is_default qr_code_url logo_url].freeze, + synced: %w[id name display_state is_default config].freeze, ignored: %w[tournament_id created_at updated_at sync_source_id].freeze }.freeze, Stage => { diff --git a/db/migrate/20260426120000_replace_beamer_asset_fields_with_config.rb b/db/migrate/20260426120000_replace_beamer_asset_fields_with_config.rb new file mode 100644 index 0000000..f9b8f99 --- /dev/null +++ b/db/migrate/20260426120000_replace_beamer_asset_fields_with_config.rb @@ -0,0 +1,28 @@ +# frozen_string_literal: true + +class ReplaceBeamerAssetFieldsWithConfig < ActiveRecord::Migration[7.0] + def up + add_column :beamers, :config, :json, null: false, default: {} + + execute <<~SQL.squish + UPDATE beamers + SET display_state = CASE display_state + WHEN 'groups' THEN 'group_tables' + WHEN 'between_games' THEN 'between_games' + WHEN 'during_game' THEN 'during_game' + ELSE 'during_game' + END + SQL + + change_column_default :beamers, :display_state, from: 'matches_all', to: 'during_game' + remove_column :beamers, :qr_code_url, :string + remove_column :beamers, :logo_url, :string + end + + def down + add_column :beamers, :qr_code_url, :string + add_column :beamers, :logo_url, :string + change_column_default :beamers, :display_state, from: 'during_game', to: 'matches_all' + remove_column :beamers, :config, :json + end +end diff --git a/db/schema.rb b/db/schema.rb index 656b26b..1884bd4 100644 --- a/db/schema.rb +++ b/db/schema.rb @@ -10,17 +10,16 @@ # # It's strongly recommended that you check this file into your version control system. -ActiveRecord::Schema[7.0].define(version: 2026_04_24_120000) do +ActiveRecord::Schema[7.0].define(version: 2026_04_26_120000) do create_table "beamers", force: :cascade do |t| t.string "name", null: false - t.string "display_state", default: "matches_all", null: false + t.string "display_state", default: "during_game", null: false t.boolean "is_default", default: false - t.string "qr_code_url" - t.string "logo_url" t.integer "tournament_id", null: false t.datetime "created_at", null: false t.datetime "updated_at", null: false t.integer "sync_source_id" + t.json "config", default: {}, null: false t.index ["tournament_id", "name"], name: "index_beamers_on_tournament_id_and_name", unique: true t.index ["tournament_id"], name: "index_beamers_on_tournament_id" end diff --git a/doc/beamer_live_updates.md b/doc/beamer_live_updates.md index 155349e..8465a1f 100644 --- a/doc/beamer_live_updates.md +++ b/doc/beamer_live_updates.md @@ -30,9 +30,10 @@ Initial subscribe snapshot: "beamer": { "id": 34, "name": "default", - "display_state": "matches_all", - "qr_code_url": null, - "logo_url": null + "display_state": "during_game", + "config": { + "match_filter": "state:in_progress" + } } } ``` diff --git a/spec/controllers/beamers_controller_spec.rb b/spec/controllers/beamers_controller_spec.rb index bc76847..3a4fb46 100644 --- a/spec/controllers/beamers_controller_spec.rb +++ b/spec/controllers/beamers_controller_spec.rb @@ -66,7 +66,11 @@ RSpec.describe BeamersController, type: :controller do describe 'POST #create' do let(:valid_params) do - { name: 'new-beamer', display_state: 'groups' } + { + name: 'new-beamer', + display_state: 'during_game', + config: { match_filter: 'state:in_progress' } + } end context 'as owner' do @@ -83,7 +87,8 @@ RSpec.describe BeamersController, type: :controller do post :create, params: { tournament_id: @tournament.to_param }.merge(valid_params) body = deserialize_response response expect(body[:name]).to eq('new-beamer') - expect(body[:display_state]).to eq('groups') + expect(body[:display_state]).to eq('during_game') + expect(body[:config]).to eq(match_filter: 'state:in_progress') end it 'rejects invalid display_state' do @@ -95,7 +100,7 @@ RSpec.describe BeamersController, type: :controller do it 'rejects duplicate name in same tournament' do post :create, params: { - tournament_id: @tournament.to_param, name: @beamer.name, display_state: 'matches_all' + tournament_id: @tournament.to_param, name: @beamer.name, display_state: 'during_game' } expect(response).to have_http_status(:unprocessable_entity) end @@ -133,15 +138,18 @@ RSpec.describe BeamersController, type: :controller do expect(@beamer.reload.display_state).to eq('during_game') end - it 'updates optional fields' do + it 'replaces config' do + @beamer.update!(config: { match_filter: 'state:upcoming', image_key: 'old-logo' }) + patch :update, params: { tournament_id: @tournament.to_param, id: @beamer.to_param, - qr_code_url: 'https://example.com/qr', logo_url: '/static/logo.png' + config: { image_key: 'bpwstr_logo' } } expect(response).to be_successful + body = deserialize_response response + expect(body[:config]).to eq(image_key: 'bpwstr_logo') @beamer.reload - expect(@beamer.qr_code_url).to eq('https://example.com/qr') - expect(@beamer.logo_url).to eq('/static/logo.png') + expect(@beamer.config).to eq('image_key' => 'bpwstr_logo') end it 'rejects invalid display_state' do @@ -159,7 +167,7 @@ RSpec.describe BeamersController, type: :controller do it 'returns forbidden' do patch :update, params: { tournament_id: @tournament.to_param, id: @beamer.to_param, - display_state: 'groups' + display_state: 'group_tables' } expect(response).to have_http_status(:forbidden) end @@ -174,7 +182,7 @@ RSpec.describe BeamersController, type: :controller do it 'returns locked' do patch :update, params: { tournament_id: @tournament.to_param, id: @beamer.to_param, - display_state: 'groups' + display_state: 'group_tables' } expect(response).to have_http_status(:locked) end diff --git a/spec/e2e/http/tournament_beamers_websocket_spec.rb b/spec/e2e/http/tournament_beamers_websocket_spec.rb index 4588172..ffeff40 100644 --- a/spec/e2e/http/tournament_beamers_websocket_spec.rb +++ b/spec/e2e/http/tournament_beamers_websocket_spec.rb @@ -26,7 +26,10 @@ RSpec.describe 'Tournament beamer websocket HTTP E2E' do tournament = create_tournament(name_prefix: 'Ws Beamer') create_beamer = client.post("/tournaments/#{tournament.fetch(:id)}/beamers", body: { name: 'projector-a', - display_state: 'matches_all' + display_state: 'during_game', + config: { + match_filter: 'state:in_progress' + } }) expect(create_beamer[:status]).to eq(201) beamer = create_beamer.fetch(:json) @@ -37,22 +40,25 @@ RSpec.describe 'Tournament beamer websocket HTTP E2E' do initial_payload = cable_client.wait_for_beamer_payload! expect(initial_payload.fetch('type')).to eq('beamer.snapshot') expect(initial_payload.dig('beamer', 'id')).to eq(beamer.fetch(:id)) - expect(initial_payload.dig('beamer', 'display_state')).to eq('matches_all') + expect(initial_payload.dig('beamer', 'display_state')).to eq('during_game') + expect(initial_payload.dig('beamer', 'config', 'match_filter')).to eq('state:in_progress') update = client.patch("/tournaments/#{tournament.fetch(:id)}/beamers/#{beamer.fetch(:id)}", body: { - display_state: 'during_game', - qr_code_url: 'https://example.com/qr', - logo_url: '/static/logo.png' + display_state: 'static_image', + config: { + image_key: 'bpwstr_logo' + } }) expect(update[:status]).to eq(200) updated_payload = wait_for_beamer_payload!( cable_client, event_type: 'beamer.updated', - display_state: 'during_game' + display_state: 'static_image' ) - expect(updated_payload.dig('beamer', 'qr_code_url')).to eq('https://example.com/qr') - expect(updated_payload.dig('beamer', 'logo_url')).to eq('/static/logo.png') + expect(updated_payload.dig('beamer', 'config')).to eq('image_key' => 'bpwstr_logo') + expect(updated_payload['beamer']).not_to have_key('qr_code_url') + expect(updated_payload['beamer']).not_to have_key('logo_url') delete = client.delete("/tournaments/#{tournament.fetch(:id)}/beamers/#{beamer.fetch(:id)}") expect(delete[:status]).to eq(204) @@ -60,7 +66,7 @@ RSpec.describe 'Tournament beamer websocket HTTP E2E' do deleted_payload = wait_for_beamer_payload!( cable_client, event_type: 'beamer.deleted', - display_state: 'during_game' + display_state: 'static_image' ) expect(deleted_payload.dig('beamer', 'name')).to eq('projector-a') ensure diff --git a/spec/e2e/http/tournament_follow_sync_beamers_websocket_spec.rb b/spec/e2e/http/tournament_follow_sync_beamers_websocket_spec.rb index 19eedf8..b550f67 100644 --- a/spec/e2e/http/tournament_follow_sync_beamers_websocket_spec.rb +++ b/spec/e2e/http/tournament_follow_sync_beamers_websocket_spec.rb @@ -32,7 +32,7 @@ RSpec.describe 'Tournament follower sync beamer websocket HTTP E2E' do follower = create_follower_tournament(name_prefix: 'Ws Beamer Follower') configure_sync = source_client.patch("/tournaments/#{source.fetch(:id)}", body: { - sync_target_url: "#{follower_base_url}/tournaments/#{follower.fetch(:id)}/sync_state", + sync_target_url: follower_sync_url(follower), sync_auth_token: sync_token }) expect(configure_sync[:status]).to eq(200) @@ -40,21 +40,35 @@ RSpec.describe 'Tournament follower sync beamer websocket HTTP E2E' do follower_default_beamer = wait_for_default_beamer!(tournament_id: follower.fetch(:id)) cable_client = TurniereE2E::ActionCableClient.new(base_url: follower_base_url) - cable_client.subscribe_beamer!(tournament_id: follower.fetch(:id), beamer_name: follower_default_beamer.fetch(:name)) + cable_client.subscribe_beamer!( + tournament_id: follower.fetch(:id), + beamer_name: follower_default_beamer.fetch(:name) + ) cable_client.wait_for_beamer_payload! - source_default_beamer = fetch_beamer(base_url: source_base_url, tournament_id: source.fetch(:id), beamer_name: DEFAULT_BEAMER_NAME) - update = source_client.patch("/tournaments/#{source.fetch(:id)}/beamers/#{source_default_beamer.fetch(:id)}", body: { - display_state: 'groups' - }) + source_default_beamer = fetch_beamer( + base_url: source_base_url, + tournament_id: source.fetch(:id), + beamer_name: DEFAULT_BEAMER_NAME + ) + update = source_client.patch( + "/tournaments/#{source.fetch(:id)}/beamers/#{source_default_beamer.fetch(:id)}", + body: { + display_state: 'static_image', + config: { + image_key: 'bpwstr_logo' + } + } + ) expect(update[:status]).to eq(200) follower_payload = wait_for_beamer_payload!( cable_client, event_type: 'beamer.updated', - display_state: 'groups' + display_state: 'static_image' ) expect(follower_payload.dig('beamer', 'name')).to eq(DEFAULT_BEAMER_NAME) + expect(follower_payload.dig('beamer', 'config')).to eq('image_key' => 'bpwstr_logo') ensure cable_client&.close end @@ -99,6 +113,10 @@ RSpec.describe 'Tournament follower sync beamer websocket HTTP E2E' do response.fetch(:json) end + def follower_sync_url(follower) + "#{follower_base_url}/tournaments/#{follower.fetch(:id)}/sync_state" + end + def fetch_beamer(base_url:, tournament_id:, beamer_name:) client = TurniereE2E::ApiClient.new(base_url:) response = client.get("/tournaments/#{tournament_id}/beamers/by_name", params: { name: beamer_name }) diff --git a/spec/factories/beamers.rb b/spec/factories/beamers.rb index 0d6f3a0..354c43c 100644 --- a/spec/factories/beamers.rb +++ b/spec/factories/beamers.rb @@ -3,7 +3,8 @@ FactoryBot.define do factory :beamer do sequence(:name) { |n| "beamer-#{n}" } - display_state { 'matches_all' } + display_state { 'during_game' } + config { {} } tournament factory :default_beamer do diff --git a/spec/models/beamer_spec.rb b/spec/models/beamer_spec.rb index ba9681b..d3e82de 100644 --- a/spec/models/beamer_spec.rb +++ b/spec/models/beamer_spec.rb @@ -24,6 +24,12 @@ RSpec.describe Beamer, type: :model do expect(beamer).not_to be_valid end + it 'requires config to be an object' do + beamer = build(:beamer, config: ['not-an-object']) + expect(beamer).not_to be_valid + expect(beamer.errors[:config]).to include('must be an object') + end + Beamer::DISPLAY_STATES.each do |state| it "accepts display_state '#{state}'" do beamer = build(:beamer, display_state: state) @@ -54,10 +60,15 @@ RSpec.describe Beamer, type: :model do expect(args.fetch(:beamer_names)).to eq(['projector']) end - tournament.beamers.create!(name: 'projector', display_state: 'groups') + tournament.beamers.create!( + name: 'projector', + display_state: 'group_tables', + config: { 'match_filter' => 'state:in_progress' } + ) expect(payload[:type]).to eq('beamer.created') - expect(payload.dig(:beamer, :display_state)).to eq('groups') + expect(payload.dig(:beamer, :display_state)).to eq('group_tables') + expect(payload.dig(:beamer, :config)).to eq('match_filter' => 'state:in_progress') end it 'broadcasts update payload to previous and current beamer names when renamed' do @@ -69,15 +80,17 @@ RSpec.describe Beamer, type: :model do expect(args.fetch(:beamer_names)).to contain_exactly('old-name', 'new-name') end - beamer.update!(name: 'new-name', display_state: 'during_game') + beamer.update!(name: 'new-name', display_state: 'during_game', config: { 'image_key' => 'bpwstr_logo' }) expect(payload[:type]).to eq('beamer.updated') expect(payload.dig(:beamer, :name)).to eq('new-name') expect(payload.dig(:beamer, :display_state)).to eq('during_game') + expect(payload.dig(:beamer, :config)).to eq('image_key' => 'bpwstr_logo') end it 'broadcasts delete payload with last persisted snapshot' do - beamer = create(:beamer, name: 'projector', display_state: 'between_games') + beamer = create(:beamer, name: 'projector', display_state: 'between_games', + config: { 'match_filter' => 'state:in_progress' }) payload = nil expect(BeamerBroadcast).to receive(:call) do |args| @@ -90,6 +103,7 @@ RSpec.describe Beamer, type: :model do expect(payload[:type]).to eq('beamer.deleted') expect(payload.dig(:beamer, :name)).to eq('projector') expect(payload.dig(:beamer, :display_state)).to eq('between_games') + expect(payload.dig(:beamer, :config)).to eq('match_filter' => 'state:in_progress') end end end diff --git a/spec/services/tournament_snapshot_importer_spec.rb b/spec/services/tournament_snapshot_importer_spec.rb index 29292ee..03f9b1a 100644 --- a/spec/services/tournament_snapshot_importer_spec.rb +++ b/spec/services/tournament_snapshot_importer_spec.rb @@ -6,7 +6,11 @@ RSpec.describe TournamentSnapshotImporter do describe '.import!' do it 'replaces follower tournament graph using source ids as stable mapping keys' do source = create(:group_stage_tournament, group_count: 2, match_factory: :filled_group_match) - source.beamers.create!(name: 'stream-a', display_state: 'during_game', qr_code_url: 'https://example.com/qr') + source.beamers.create!( + name: 'stream-a', + display_state: 'during_game', + config: { match_filter: 'state:in_progress' } + ) list = create(:team_action_list, tournament: source) create(:team_action_item, team_action_list: list, team: source.teams.first, completed: true) follower = create(:tournament, read_only_mode: true, sync_auth_token: 'shared-secret') @@ -78,21 +82,18 @@ RSpec.describe TournamentSnapshotImporter do intermediate_round_participants_amount: 0 ) tournament.beamers.find_by!(name: Beamer::DEFAULT_NAME).update!( - display_state: 'groups', - qr_code_url: 'https://example.com/default-qr', - logo_url: '/logos/default.png' + display_state: 'group_tables', + config: { match_filter: 'state:in_progress' } ) tournament.beamers.create!( name: 'stream-a', display_state: 'during_game', - qr_code_url: 'https://example.com/stream-a', - logo_url: '/logos/stream-a.png' + config: { match_filter: 'state:in_progress' } ) tournament.beamers.create!( name: 'stream-b', display_state: 'between_games', - qr_code_url: 'https://example.com/stream-b', - logo_url: '/logos/stream-b.png' + config: { image_key: 'bpwstr_logo' } ) group_stage = Stage.create!(tournament: tournament, level: -1, state: :finished) @@ -202,7 +203,7 @@ RSpec.describe TournamentSnapshotImporter do } end, beamers: tournament.beamers.order(:name).map do |beamer| - beamer.slice(:name, :display_state, :is_default, :qr_code_url, :logo_url).symbolize_keys + beamer.slice(:name, :display_state, :is_default, :config).symbolize_keys end, stages: tournament.stages.order(level: :asc).map do |stage| { From 366ee038f24bd2a5f879eee81621af7679d4df81 Mon Sep 17 00:00:00 2001 From: Malaber Date: Sun, 26 Apr 2026 18:48:50 +0200 Subject: [PATCH 4/7] test: cover beamer e2e controls --- spec/e2e/http/tournament_beamers_spec.rb | 154 ++++++++++++++ .../tournament_follow_sync_beamers_spec.rb | 198 ++++++++++++++++++ tasks.py | 1 + 3 files changed, 353 insertions(+) create mode 100644 spec/e2e/http/tournament_beamers_spec.rb create mode 100644 spec/e2e/http/tournament_follow_sync_beamers_spec.rb diff --git a/spec/e2e/http/tournament_beamers_spec.rb b/spec/e2e/http/tournament_beamers_spec.rb new file mode 100644 index 0000000..268dded --- /dev/null +++ b/spec/e2e/http/tournament_beamers_spec.rb @@ -0,0 +1,154 @@ +# frozen_string_literal: true + +require 'spec_helper' +require 'securerandom' +require_relative '../../../e2e/lib/api_client' + +RSpec.describe 'Tournament beamer HTTP E2E' do + required_env_vars = %w[ + TURNIERE_E2E_BASE_URL + TURNIERE_E2E_EMAIL + TURNIERE_E2E_PASSWORD + TURNIERE_E2E_ALT_EMAIL + TURNIERE_E2E_ALT_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(:other_email) { ENV.fetch('TURNIERE_E2E_ALT_EMAIL') } + let(:other_password) { ENV.fetch('TURNIERE_E2E_ALT_PASSWORD') } + let(:owner_client) { login_client(email: owner_email, password: owner_password) } + let(:other_client) { login_client(email: other_email, password: other_password) } + let(:anonymous_client) { TurniereE2E::ApiClient.new(base_url:) } + + it 'exercises beamer control endpoints over HTTP' do + tournament = create_tournament(client: owner_client, name_prefix: 'Beamer Control') + + default_beamer = anonymous_client.get( + beamer_by_name_path(tournament.fetch(:id)), + params: { name: 'missing' } + ) + default_show = anonymous_client.get(beamer_path(tournament.fetch(:id), default_beamer.dig(:json, :id))) + + expect(default_beamer[:status]).to eq(200) + expect(default_beamer.dig(:json, :name)).to eq('default') + expect(default_beamer.dig(:json, :display_state)).to eq('matches_all') + expect(default_show[:status]).to eq(200) + + create_response = owner_client.post("/tournaments/#{tournament.fetch(:id)}/beamers", body: { + name: 'hall-a', + display_state: 'groups', + qr_code_url: 'https://example.com/hall-a', + logo_url: '/logos/hall-a.png' + }) + expect(create_response[:status]).to eq(201) + beamer = create_response.fetch(:json) + expect(beamer.fetch(:name)).to eq('hall-a') + expect(beamer.fetch(:display_state)).to eq('groups') + expect(beamer.fetch(:qr_code_url)).to eq('https://example.com/hall-a') + + index_response = anonymous_client.get("/tournaments/#{tournament.fetch(:id)}/beamers") + show_response = anonymous_client.get("/tournaments/#{tournament.fetch(:id)}/beamers/#{beamer.fetch(:id)}") + by_name_response = anonymous_client.get(beamer_by_name_path(tournament.fetch(:id)), params: { name: 'hall-a' }) + + expect(index_response[:status]).to eq(200) + expect(index_response.fetch(:json).map { |item| item.fetch(:name) }).to contain_exactly('default', 'hall-a') + expect(show_response[:status]).to eq(200) + expect(show_response.dig(:json, :id)).to eq(beamer.fetch(:id)) + expect(by_name_response[:status]).to eq(200) + expect(by_name_response.dig(:json, :id)).to eq(beamer.fetch(:id)) + + update_response = owner_client.patch("/tournaments/#{tournament.fetch(:id)}/beamers/#{beamer.fetch(:id)}", body: { + display_state: 'during_game', + qr_code_url: 'https://example.com/live', + logo_url: '/logos/live.png' + }) + expect(update_response[:status]).to eq(200) + expect(update_response.dig(:json, :display_state)).to eq('during_game') + expect(update_response.dig(:json, :qr_code_url)).to eq('https://example.com/live') + expect(update_response.dig(:json, :logo_url)).to eq('/logos/live.png') + + invalid_update = owner_client.patch("/tournaments/#{tournament.fetch(:id)}/beamers/#{beamer.fetch(:id)}", body: { + display_state: 'bogus' + }) + duplicate_create = owner_client.post("/tournaments/#{tournament.fetch(:id)}/beamers", body: { + name: 'hall-a', + display_state: 'matches_all' + }) + forbidden_update = other_client.patch("/tournaments/#{tournament.fetch(:id)}/beamers/#{beamer.fetch(:id)}", body: { + display_state: 'groups' + }) + unauthenticated_create = anonymous_client.post( + "/tournaments/#{tournament.fetch(:id)}/beamers", + body: { + name: 'anon', + display_state: 'groups' + } + ) + + expect(invalid_update[:status]).to eq(422) + expect(duplicate_create[:status]).to eq(422) + expect(forbidden_update[:status]).to eq(403) + expect(unauthenticated_create[:status]).to eq(401) + + read_only_tournament = create_tournament( + client: owner_client, + name_prefix: 'Beamer Read Only', + read_only_mode: true + ) + read_only_beamer = anonymous_client.get( + beamer_by_name_path(read_only_tournament.fetch(:id)), + params: { name: 'default' } + ) + locked_update = owner_client.patch( + beamer_path(read_only_tournament.fetch(:id), read_only_beamer.dig(:json, :id)), + body: { display_state: 'groups' } + ) + expect(locked_update[:status]).to eq(423) + + delete_response = owner_client.delete("/tournaments/#{tournament.fetch(:id)}/beamers/#{beamer.fetch(:id)}") + deleted_show = anonymous_client.get("/tournaments/#{tournament.fetch(:id)}/beamers/#{beamer.fetch(:id)}") + deleted_by_name = anonymous_client.get(beamer_by_name_path(tournament.fetch(:id)), params: { name: 'hall-a' }) + + expect(delete_response[:status]).to eq(204) + expect(deleted_show[:status]).to eq(404) + expect(deleted_by_name[:status]).to eq(200) + expect(deleted_by_name.dig(:json, :name)).to eq('default') + end + + def login_client(email:, password:) + api_client = TurniereE2E::ApiClient.new(base_url:) + response = api_client.login!(email:, password:) + + expect(response[:status]).to eq(200) + api_client + end + + def create_tournament(client:, name_prefix:, read_only_mode: false) + response = client.post('/tournaments', body: { + name: "#{name_prefix} #{SecureRandom.hex(3)}", + description: 'HTTP beamer control tournament', + public: true, + read_only_mode:, + teams: Array.new(4) do |index| + { name: "#{name_prefix.tr(' ', '')}-S#{index + 1}" } + end + }) + expect(response[:status]).to eq(201) + response.fetch(:json) + end + + def beamer_path(tournament_id, beamer_id) + "/tournaments/#{tournament_id}/beamers/#{beamer_id}" + end + + def beamer_by_name_path(tournament_id) + "/tournaments/#{tournament_id}/beamers/by_name" + end +end diff --git a/spec/e2e/http/tournament_follow_sync_beamers_spec.rb b/spec/e2e/http/tournament_follow_sync_beamers_spec.rb new file mode 100644 index 0000000..4095fda --- /dev/null +++ b/spec/e2e/http/tournament_follow_sync_beamers_spec.rb @@ -0,0 +1,198 @@ +# frozen_string_literal: true + +require 'spec_helper' +require 'securerandom' +require_relative '../../../e2e/lib/api_client' + +RSpec.describe 'Tournament follower sync beamer 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_client) { login_client(base_url: follower_base_url) } + let(:follower_read_client) { TurniereE2E::ApiClient.new(base_url: follower_base_url) } + + it 'mirrors leader beamer control changes into follower HTTP reads' do + source = create_tournament(client: source_client, name_prefix: 'Beamer Source') + follower = create_tournament(client: follower_client, name_prefix: 'Beamer Follower', read_only_mode: true) + + configure_sync = source_client.patch("/tournaments/#{source.fetch(:id)}", body: { + sync_target_url: sync_state_url(follower.fetch(:id)), + sync_auth_token: sync_token + }) + expect(configure_sync[:status]).to eq(200) + sync_now_if_requested!(source.fetch(:id)) + + source_default = fetch_beamer(client: source_client, tournament_id: source.fetch(:id), beamer_name: 'default') + follower_default = wait_for_follower_beamer!( + tournament_id: follower.fetch(:id), + beamer_name: 'default', + display_state: source_default.fetch(:display_state) + ) + expect(follower_default.fetch(:name)).to eq('default') + + leader_default_update = source_client.patch( + beamer_path(source.fetch(:id), source_default.fetch(:id)), + body: { + display_state: 'between_games', + qr_code_url: 'https://example.com/leader-default', + logo_url: '/logos/default.png' + } + ) + expect(leader_default_update[:status]).to eq(200) + sync_now_if_requested!(source.fetch(:id)) + + mirrored_default = wait_for_follower_beamer!( + tournament_id: follower.fetch(:id), + beamer_name: 'default', + display_state: 'between_games', + qr_code_url: 'https://example.com/leader-default', + logo_url: '/logos/default.png' + ) + expect(mirrored_default.fetch(:name)).to eq('default') + + leader_create = source_client.post("/tournaments/#{source.fetch(:id)}/beamers", body: { + name: 'stage-left', + display_state: 'groups', + qr_code_url: 'https://example.com/stage-left', + logo_url: '/logos/stage-left.png' + }) + expect(leader_create[:status]).to eq(201) + sync_now_if_requested!(source.fetch(:id)) + + mirrored_created = wait_for_follower_beamer!( + tournament_id: follower.fetch(:id), + beamer_name: 'stage-left', + display_state: 'groups', + qr_code_url: 'https://example.com/stage-left', + logo_url: '/logos/stage-left.png' + ) + expect(mirrored_created.fetch(:name)).to eq('stage-left') + + leader_update = source_client.patch( + beamer_path(source.fetch(:id), leader_create.dig(:json, :id)), + body: { + display_state: 'during_game', + qr_code_url: 'https://example.com/stage-left-live' + } + ) + expect(leader_update[:status]).to eq(200) + sync_now_if_requested!(source.fetch(:id)) + + mirrored_updated = wait_for_follower_beamer!( + tournament_id: follower.fetch(:id), + beamer_name: 'stage-left', + display_state: 'during_game', + qr_code_url: 'https://example.com/stage-left-live' + ) + expect(mirrored_updated.fetch(:logo_url)).to eq('/logos/stage-left.png') + + locked_follower_update = follower_client.patch( + beamer_path(follower.fetch(:id), mirrored_updated.fetch(:id)), + body: { display_state: 'matches_finished' } + ) + expect(locked_follower_update[:status]).to eq(423) + + leader_delete = source_client.delete("/tournaments/#{source.fetch(:id)}/beamers/#{leader_create.dig(:json, :id)}") + expect(leader_delete[:status]).to eq(204) + sync_now_if_requested!(source.fetch(:id)) + + wait_for_follower_beamer_removed!( + tournament_id: follower.fetch(:id), + beamer_name: 'stage-left' + ) + end + + def login_client(base_url:) + 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_tournament(client:, name_prefix:, read_only_mode: false) + response = client.post('/tournaments', body: tournament_payload(name_prefix:, read_only_mode:)) + expect(response[:status]).to eq(201) + response.fetch(:json) + end + + def fetch_beamer(client:, tournament_id:, beamer_name:) + response = client.get(beamer_by_name_path(tournament_id), params: { name: beamer_name }) + expect(response[:status]).to eq(200) + response.fetch(:json) + end + + def sync_now_if_requested!(source_tournament_id) + return unless ENV.fetch('TURNIERE_E2E_SYNC_MODE', nil) == 'manual' + + response = source_client.post("/tournaments/#{source_tournament_id}/test_sync") + expect(response[:status]).to eq(200) + end + + def wait_for_follower_beamer!(tournament_id:, beamer_name:, timeout: 20, **expected_attrs) + deadline = Time.now.utc + timeout + loop do + response = follower_read_client.get(beamer_by_name_path(tournament_id), params: { name: beamer_name }) + if response[:status] == 200 && response.dig(:json, :name) == beamer_name + beamer = response.fetch(:json) + return beamer if expected_attrs.all? { |key, value| beamer.fetch(key) == value } + end + + raise "follower beamer #{beamer_name} not synced within #{timeout}s" if Time.now.utc >= deadline + + sleep 0.5 + end + end + + def wait_for_follower_beamer_removed!(tournament_id:, beamer_name:, timeout: 20) + deadline = Time.now.utc + timeout + loop do + response = follower_read_client.get(beamer_by_name_path(tournament_id), params: { name: beamer_name }) + return if response[:status] == 200 && response.dig(:json, :name) != beamer_name + + raise "follower beamer #{beamer_name} not removed within #{timeout}s" if Time.now.utc >= deadline + + sleep 0.5 + end + end + + def tournament_payload(name_prefix:, read_only_mode:) + { + name: "#{name_prefix} #{SecureRandom.hex(3)}", + description: 'Follower beamer HTTP tournament', + public: true, + read_only_mode:, + sync_auth_token: read_only_mode ? sync_token : nil, + teams: Array.new(4) do |index| + { name: "#{name_prefix.tr(' ', '')}-S#{index + 1}" } + end + }.compact + end + + def sync_state_url(tournament_id) + "#{follower_base_url}/tournaments/#{tournament_id}/sync_state" + end + + def beamer_path(tournament_id, beamer_id) + "/tournaments/#{tournament_id}/beamers/#{beamer_id}" + end + + def beamer_by_name_path(tournament_id) + "/tournaments/#{tournament_id}/beamers/by_name" + end +end diff --git a/tasks.py b/tasks.py index a1b8ac3..e2de58e 100644 --- a/tasks.py +++ b/tasks.py @@ -1764,6 +1764,7 @@ def blackbox_follow_service( alt_username, spec_path=[ "spec/e2e/http/tournament_follow_sync_spec.rb", + "spec/e2e/http/tournament_follow_sync_beamers_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", From 51170f1b7f9d164ad676df1e7bb38c02ef2b65ea Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Daniel=20Sch=C3=A4dler?= Date: Sun, 26 Apr 2026 20:45:39 +0000 Subject: [PATCH 5/7] Switch from unknown to other dev version --- Dockerfile | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/Dockerfile b/Dockerfile index e23ba69..7e7d590 100644 --- a/Dockerfile +++ b/Dockerfile @@ -54,7 +54,7 @@ RUN bundle config set with 'development test' \ FROM runtime-base AS production -ARG GIT_COMMIT_SHA=unknown +ARG GIT_COMMIT_SHA=aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa ENV RAILS_ENV=production ENV GIT_COMMIT_SHA=${GIT_COMMIT_SHA} @@ -78,7 +78,7 @@ CMD ["sh", "-lc", "bundle exec rails db:migrate && bundle exec rails s -p 3000 - FROM runtime-base AS test -ARG GIT_COMMIT_SHA=unknown +ARG GIT_COMMIT_SHA=aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa ENV RAILS_ENV=test ENV GIT_COMMIT_SHA=${GIT_COMMIT_SHA} From 68b72cc01a945d85e530f0684710745bad694246 Mon Sep 17 00:00:00 2001 From: Malaber Date: Sun, 26 Apr 2026 23:45:35 +0200 Subject: [PATCH 6/7] test: update beamer e2e contract --- app/models/beamer.rb | 1 + app/models/tournament.rb | 27 ++++++---- ...replace_beamer_asset_fields_with_config.rb | 6 +++ spec/e2e/http/tournament_beamers_spec.rb | 45 ++++++++++------ .../tournament_follow_sync_beamers_spec.rb | 54 ++++++++++++------- spec/factories/beamers.rb | 3 +- spec/models/tournament_spec.rb | 3 ++ 7 files changed, 93 insertions(+), 46 deletions(-) diff --git a/app/models/beamer.rb b/app/models/beamer.rb index 3170cf0..90405af 100644 --- a/app/models/beamer.rb +++ b/app/models/beamer.rb @@ -2,6 +2,7 @@ class Beamer < ApplicationRecord DEFAULT_NAME = 'default' + DEFAULT_CONFIG = { 'match_filter' => 'state:in_progress' }.freeze DISPLAY_STATES = %w[ during_game between_games static_image group_tables ].freeze diff --git a/app/models/tournament.rb b/app/models/tournament.rb index b91176a..3debf27 100644 --- a/app/models/tournament.rb +++ b/app/models/tournament.rb @@ -67,27 +67,32 @@ class Tournament < ApplicationRecord end def sync_configuration_is_complete - return if sync_target_url.blank? && sync_auth_token.blank? - return if sync_target_url.present? && sync_auth_token.present? - return if read_only_mode? && sync_auth_token.present? && sync_target_url.blank? + return if sync_configuration_blank? + return if sync_configuration_complete? + return if follower_sync_token_only? errors.add(:base, 'sync_target_url and sync_auth_token must be configured together') end + def sync_configuration_blank? + sync_target_url.blank? && sync_auth_token.blank? + end + + def sync_configuration_complete? + sync_target_url.present? && sync_auth_token.present? + end + + def follower_sync_token_only? + read_only_mode? && sync_auth_token.present? && sync_target_url.blank? + end + def ensure_default_beamer! beamers.find_or_create_by!(name: Beamer::DEFAULT_NAME) do |beamer| beamer.display_state = 'during_game' + beamer.config = Beamer::DEFAULT_CONFIG end end - def sync_configuration_is_complete - return if sync_target_url.blank? && sync_auth_token.blank? - return if sync_target_url.present? && sync_auth_token.present? - return if read_only_mode? && sync_auth_token.present? && sync_target_url.blank? - - errors.add(:base, 'sync_target_url and sync_auth_token must be configured together') - end - def clear_follower_sync_token_when_disabling_read_only_mode return if read_only_mode? return if sync_target_url.present? diff --git a/db/migrate/20260426120000_replace_beamer_asset_fields_with_config.rb b/db/migrate/20260426120000_replace_beamer_asset_fields_with_config.rb index f9b8f99..c726149 100644 --- a/db/migrate/20260426120000_replace_beamer_asset_fields_with_config.rb +++ b/db/migrate/20260426120000_replace_beamer_asset_fields_with_config.rb @@ -14,6 +14,12 @@ class ReplaceBeamerAssetFieldsWithConfig < ActiveRecord::Migration[7.0] END SQL + execute <<~SQL.squish + UPDATE beamers + SET config = '{"match_filter":"state:in_progress"}' + WHERE name = 'default' + SQL + change_column_default :beamers, :display_state, from: 'matches_all', to: 'during_game' remove_column :beamers, :qr_code_url, :string remove_column :beamers, :logo_url, :string diff --git a/spec/e2e/http/tournament_beamers_spec.rb b/spec/e2e/http/tournament_beamers_spec.rb index 268dded..a5f0625 100644 --- a/spec/e2e/http/tournament_beamers_spec.rb +++ b/spec/e2e/http/tournament_beamers_spec.rb @@ -38,20 +38,26 @@ RSpec.describe 'Tournament beamer HTTP E2E' do expect(default_beamer[:status]).to eq(200) expect(default_beamer.dig(:json, :name)).to eq('default') - expect(default_beamer.dig(:json, :display_state)).to eq('matches_all') + expect(default_beamer.dig(:json, :display_state)).to eq('during_game') + expect(default_beamer.dig(:json, :config)).to eq(match_filter: 'state:in_progress') expect(default_show[:status]).to eq(200) + expect(default_show.dig(:json, :config)).to eq(match_filter: 'state:in_progress') create_response = owner_client.post("/tournaments/#{tournament.fetch(:id)}/beamers", body: { name: 'hall-a', - display_state: 'groups', - qr_code_url: 'https://example.com/hall-a', - logo_url: '/logos/hall-a.png' + display_state: 'group_tables', + config: { + match_filter: 'stage:groups', + table_view: 'compact' + } }) expect(create_response[:status]).to eq(201) beamer = create_response.fetch(:json) expect(beamer.fetch(:name)).to eq('hall-a') - expect(beamer.fetch(:display_state)).to eq('groups') - expect(beamer.fetch(:qr_code_url)).to eq('https://example.com/hall-a') + expect(beamer.fetch(:display_state)).to eq('group_tables') + expect(beamer.fetch(:config)).to eq(match_filter: 'stage:groups', table_view: 'compact') + expect(beamer).not_to have_key(:qr_code_url) + expect(beamer).not_to have_key(:logo_url) index_response = anonymous_client.get("/tournaments/#{tournament.fetch(:id)}/beamers") show_response = anonymous_client.get("/tournaments/#{tournament.fetch(:id)}/beamers/#{beamer.fetch(:id)}") @@ -65,34 +71,41 @@ RSpec.describe 'Tournament beamer HTTP E2E' do expect(by_name_response.dig(:json, :id)).to eq(beamer.fetch(:id)) update_response = owner_client.patch("/tournaments/#{tournament.fetch(:id)}/beamers/#{beamer.fetch(:id)}", body: { - display_state: 'during_game', - qr_code_url: 'https://example.com/live', - logo_url: '/logos/live.png' + display_state: 'static_image', + config: { + image_key: 'bpwstr_logo' + } }) expect(update_response[:status]).to eq(200) - expect(update_response.dig(:json, :display_state)).to eq('during_game') - expect(update_response.dig(:json, :qr_code_url)).to eq('https://example.com/live') - expect(update_response.dig(:json, :logo_url)).to eq('/logos/live.png') + expect(update_response.dig(:json, :display_state)).to eq('static_image') + expect(update_response.dig(:json, :config)).to eq(image_key: 'bpwstr_logo') + expect(update_response[:json]).not_to have_key(:qr_code_url) + expect(update_response[:json]).not_to have_key(:logo_url) invalid_update = owner_client.patch("/tournaments/#{tournament.fetch(:id)}/beamers/#{beamer.fetch(:id)}", body: { display_state: 'bogus' }) + invalid_config_update = owner_client.patch( + "/tournaments/#{tournament.fetch(:id)}/beamers/#{beamer.fetch(:id)}", + body: { config: ['not-an-object'] } + ) duplicate_create = owner_client.post("/tournaments/#{tournament.fetch(:id)}/beamers", body: { name: 'hall-a', - display_state: 'matches_all' + display_state: 'during_game' }) forbidden_update = other_client.patch("/tournaments/#{tournament.fetch(:id)}/beamers/#{beamer.fetch(:id)}", body: { - display_state: 'groups' + display_state: 'group_tables' }) unauthenticated_create = anonymous_client.post( "/tournaments/#{tournament.fetch(:id)}/beamers", body: { name: 'anon', - display_state: 'groups' + display_state: 'group_tables' } ) expect(invalid_update[:status]).to eq(422) + expect(invalid_config_update[:status]).to eq(422) expect(duplicate_create[:status]).to eq(422) expect(forbidden_update[:status]).to eq(403) expect(unauthenticated_create[:status]).to eq(401) @@ -108,7 +121,7 @@ RSpec.describe 'Tournament beamer HTTP E2E' do ) locked_update = owner_client.patch( beamer_path(read_only_tournament.fetch(:id), read_only_beamer.dig(:json, :id)), - body: { display_state: 'groups' } + body: { display_state: 'group_tables' } ) expect(locked_update[:status]).to eq(423) diff --git a/spec/e2e/http/tournament_follow_sync_beamers_spec.rb b/spec/e2e/http/tournament_follow_sync_beamers_spec.rb index 4095fda..f421e15 100644 --- a/spec/e2e/http/tournament_follow_sync_beamers_spec.rb +++ b/spec/e2e/http/tournament_follow_sync_beamers_spec.rb @@ -41,7 +41,8 @@ RSpec.describe 'Tournament follower sync beamer HTTP E2E' do follower_default = wait_for_follower_beamer!( tournament_id: follower.fetch(:id), beamer_name: 'default', - display_state: source_default.fetch(:display_state) + display_state: source_default.fetch(:display_state), + config: source_default.fetch(:config) ) expect(follower_default.fetch(:name)).to eq('default') @@ -49,8 +50,10 @@ RSpec.describe 'Tournament follower sync beamer HTTP E2E' do beamer_path(source.fetch(:id), source_default.fetch(:id)), body: { display_state: 'between_games', - qr_code_url: 'https://example.com/leader-default', - logo_url: '/logos/default.png' + config: { + match_filter: 'state:queued', + rotation_seconds: 10 + } } ) expect(leader_default_update[:status]).to eq(200) @@ -60,16 +63,22 @@ RSpec.describe 'Tournament follower sync beamer HTTP E2E' do tournament_id: follower.fetch(:id), beamer_name: 'default', display_state: 'between_games', - qr_code_url: 'https://example.com/leader-default', - logo_url: '/logos/default.png' + config: { + match_filter: 'state:queued', + rotation_seconds: 10 + } ) expect(mirrored_default.fetch(:name)).to eq('default') + expect(mirrored_default).not_to have_key(:qr_code_url) + expect(mirrored_default).not_to have_key(:logo_url) leader_create = source_client.post("/tournaments/#{source.fetch(:id)}/beamers", body: { name: 'stage-left', - display_state: 'groups', - qr_code_url: 'https://example.com/stage-left', - logo_url: '/logos/stage-left.png' + display_state: 'group_tables', + config: { + match_filter: 'stage:groups', + table_view: 'compact' + } }) expect(leader_create[:status]).to eq(201) sync_now_if_requested!(source.fetch(:id)) @@ -77,17 +86,23 @@ RSpec.describe 'Tournament follower sync beamer HTTP E2E' do mirrored_created = wait_for_follower_beamer!( tournament_id: follower.fetch(:id), beamer_name: 'stage-left', - display_state: 'groups', - qr_code_url: 'https://example.com/stage-left', - logo_url: '/logos/stage-left.png' + display_state: 'group_tables', + config: { + match_filter: 'stage:groups', + table_view: 'compact' + } ) expect(mirrored_created.fetch(:name)).to eq('stage-left') + expect(mirrored_created).not_to have_key(:qr_code_url) + expect(mirrored_created).not_to have_key(:logo_url) leader_update = source_client.patch( beamer_path(source.fetch(:id), leader_create.dig(:json, :id)), body: { - display_state: 'during_game', - qr_code_url: 'https://example.com/stage-left-live' + display_state: 'static_image', + config: { + image_key: 'stage-left-live' + } } ) expect(leader_update[:status]).to eq(200) @@ -96,14 +111,17 @@ RSpec.describe 'Tournament follower sync beamer HTTP E2E' do mirrored_updated = wait_for_follower_beamer!( tournament_id: follower.fetch(:id), beamer_name: 'stage-left', - display_state: 'during_game', - qr_code_url: 'https://example.com/stage-left-live' + display_state: 'static_image', + config: { + image_key: 'stage-left-live' + } ) - expect(mirrored_updated.fetch(:logo_url)).to eq('/logos/stage-left.png') + expect(mirrored_updated).not_to have_key(:qr_code_url) + expect(mirrored_updated).not_to have_key(:logo_url) locked_follower_update = follower_client.patch( beamer_path(follower.fetch(:id), mirrored_updated.fetch(:id)), - body: { display_state: 'matches_finished' } + body: { display_state: 'during_game' } ) expect(locked_follower_update[:status]).to eq(423) @@ -150,7 +168,7 @@ RSpec.describe 'Tournament follower sync beamer HTTP E2E' do response = follower_read_client.get(beamer_by_name_path(tournament_id), params: { name: beamer_name }) if response[:status] == 200 && response.dig(:json, :name) == beamer_name beamer = response.fetch(:json) - return beamer if expected_attrs.all? { |key, value| beamer.fetch(key) == value } + return beamer if expected_attrs.all? { |key, value| beamer[key] == value } end raise "follower beamer #{beamer_name} not synced within #{timeout}s" if Time.now.utc >= deadline diff --git a/spec/factories/beamers.rb b/spec/factories/beamers.rb index 354c43c..1d8b01e 100644 --- a/spec/factories/beamers.rb +++ b/spec/factories/beamers.rb @@ -9,9 +9,10 @@ FactoryBot.define do factory :default_beamer do name { Beamer::DEFAULT_NAME } + config { Beamer::DEFAULT_CONFIG } initialize_with do - tournament.beamers.find_or_initialize_by(name: name) + tournament.beamers.find_or_initialize_by(name:) end end end diff --git a/spec/models/tournament_spec.rb b/spec/models/tournament_spec.rb index 62fc7a4..c613d59 100644 --- a/spec/models/tournament_spec.rb +++ b/spec/models/tournament_spec.rb @@ -35,8 +35,11 @@ RSpec.describe Tournament, type: :model do describe 'default beamer' do it 'creates a default beamer automatically' do tournament = create(:tournament) + default_beamer = tournament.beamers.find_by!(name: Beamer::DEFAULT_NAME) expect(tournament.beamers.pluck(:name)).to include(Beamer::DEFAULT_NAME) + expect(default_beamer.display_state).to eq('during_game') + expect(default_beamer.config).to eq(Beamer::DEFAULT_CONFIG) end end From 35c8591af376ba313ea4891d1e564d02465ae866 Mon Sep 17 00:00:00 2001 From: Malaber Date: Sun, 26 Apr 2026 23:51:34 +0200 Subject: [PATCH 7/7] fix: move beamer backfill to new migration --- ...replace_beamer_asset_fields_with_config.rb | 6 ------ ...26121000_backfill_default_beamer_config.rb | 19 +++++++++++++++++++ db/schema.rb | 2 +- 3 files changed, 20 insertions(+), 7 deletions(-) create mode 100644 db/migrate/20260426121000_backfill_default_beamer_config.rb diff --git a/db/migrate/20260426120000_replace_beamer_asset_fields_with_config.rb b/db/migrate/20260426120000_replace_beamer_asset_fields_with_config.rb index c726149..f9b8f99 100644 --- a/db/migrate/20260426120000_replace_beamer_asset_fields_with_config.rb +++ b/db/migrate/20260426120000_replace_beamer_asset_fields_with_config.rb @@ -14,12 +14,6 @@ class ReplaceBeamerAssetFieldsWithConfig < ActiveRecord::Migration[7.0] END SQL - execute <<~SQL.squish - UPDATE beamers - SET config = '{"match_filter":"state:in_progress"}' - WHERE name = 'default' - SQL - change_column_default :beamers, :display_state, from: 'matches_all', to: 'during_game' remove_column :beamers, :qr_code_url, :string remove_column :beamers, :logo_url, :string diff --git a/db/migrate/20260426121000_backfill_default_beamer_config.rb b/db/migrate/20260426121000_backfill_default_beamer_config.rb new file mode 100644 index 0000000..a1b0f96 --- /dev/null +++ b/db/migrate/20260426121000_backfill_default_beamer_config.rb @@ -0,0 +1,19 @@ +# frozen_string_literal: true + +class BackfillDefaultBeamerConfig < ActiveRecord::Migration[7.0] + def up + execute <<~SQL.squish + UPDATE beamers + SET config = '{"match_filter":"state:in_progress"}' + WHERE name = 'default' + SQL + end + + def down + execute <<~SQL.squish + UPDATE beamers + SET config = '{}' + WHERE name = 'default' + SQL + end +end diff --git a/db/schema.rb b/db/schema.rb index 1884bd4..002741a 100644 --- a/db/schema.rb +++ b/db/schema.rb @@ -10,7 +10,7 @@ # # It's strongly recommended that you check this file into your version control system. -ActiveRecord::Schema[7.0].define(version: 2026_04_26_120000) do +ActiveRecord::Schema[7.0].define(version: 2026_04_26_121000) do create_table "beamers", force: :cascade do |t| t.string "name", null: false t.string "display_state", default: "during_game", null: false