diff --git a/Dockerfile b/Dockerfile index 9fb91e7..7e7d590 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=aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa + 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=aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa + 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/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/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/controllers/matches_controller.rb b/app/controllers/matches_controller.rb index 2d99671..53ae3f6 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 @@ -145,6 +145,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/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/app/models/beamer.rb b/app/models/beamer.rb index aedfeb5..90405af 100644 --- a/app/models/beamer.rb +++ b/app/models/beamer.rb @@ -2,15 +2,16 @@ class Beamer < ApplicationRecord DEFAULT_NAME = 'default' + DEFAULT_CONFIG = { 'match_filter' => 'state:in_progress' }.freeze 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 @@ -46,4 +47,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 f6ceb2b..3debf27 100644 --- a/app/models/tournament.rb +++ b/app/models/tournament.rb @@ -67,16 +67,29 @@ 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 = 'matches_all' + beamer.display_state = 'during_game' + beamer.config = Beamer::DEFAULT_CONFIG 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 1b90e5d..7472ffa 100644 --- a/app/services/beamer_broadcast_payload.rb +++ b/app/services/beamer_broadcast_payload.rb @@ -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_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/app/services/tournament_snapshot_importer.rb b/app/services/tournament_snapshot_importer.rb index e6192d8..26702e0 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/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/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/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 656b26b..002741a 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_121000) 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/e2e/lib/action_cable_client.rb b/e2e/lib/action_cable_client.rb index 3803f93..f6dd428 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/script/e2e_scenarios.rb b/script/e2e_scenarios.rb old mode 100644 new mode 100755 diff --git a/script/wait_for_http.rb b/script/wait_for_http.rb old mode 100644 new mode 100755 diff --git a/spec/controllers/beamers_controller_spec.rb b/spec/controllers/beamers_controller_spec.rb index 0970a09..5e1f8e8 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/controllers/matches_controller_spec.rb b/spec/controllers/matches_controller_spec.rb index 3af8cb4..bcb838f 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 2d29e30..a90e44a 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" @@ -206,6 +213,14 @@ RSpec.describe 'Backend API surface HTTP E2E' do 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' }) @@ -276,6 +291,12 @@ RSpec.describe 'Backend API surface HTTP E2E' do expect(matches_not_started[:json].pluck(:id)).to include(match.fetch(:id)) expect(matches_upcoming[:status]).to eq(200) expect(matches_upcoming[:json].pluck(: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_beamers_spec.rb b/spec/e2e/http/tournament_beamers_spec.rb new file mode 100644 index 0000000..a5f0625 --- /dev/null +++ b/spec/e2e/http/tournament_beamers_spec.rb @@ -0,0 +1,167 @@ +# 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('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: '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('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)}") + 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: 'static_image', + config: { + image_key: 'bpwstr_logo' + } + }) + expect(update_response[:status]).to eq(200) + 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: 'during_game' + }) + forbidden_update = other_client.patch("/tournaments/#{tournament.fetch(:id)}/beamers/#{beamer.fetch(:id)}", body: { + display_state: 'group_tables' + }) + unauthenticated_create = anonymous_client.post( + "/tournaments/#{tournament.fetch(:id)}/beamers", + body: { + name: 'anon', + 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) + + 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: 'group_tables' } + ) + 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_beamers_websocket_spec.rb b/spec/e2e/http/tournament_beamers_websocket_spec.rb index b976834..b636741 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_spec.rb b/spec/e2e/http/tournament_follow_sync_beamers_spec.rb new file mode 100644 index 0000000..f421e15 --- /dev/null +++ b/spec/e2e/http/tournament_follow_sync_beamers_spec.rb @@ -0,0 +1,216 @@ +# 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), + config: source_default.fetch(:config) + ) + 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', + config: { + match_filter: 'state:queued', + rotation_seconds: 10 + } + } + ) + 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', + 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: 'group_tables', + config: { + match_filter: 'stage:groups', + table_view: 'compact' + } + }) + 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: '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: 'static_image', + config: { + image_key: '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: 'static_image', + config: { + image_key: 'stage-left-live' + } + ) + 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: 'during_game' } + ) + 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[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/spec/e2e/http/tournament_follow_sync_beamers_websocket_spec.rb b/spec/e2e/http/tournament_follow_sync_beamers_websocket_spec.rb index 1e17b21..49b288d 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,23 +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 @@ -101,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/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/factories/beamers.rb b/spec/factories/beamers.rb index 3b5d51f..1d8b01e 100644 --- a/spec/factories/beamers.rb +++ b/spec/factories/beamers.rb @@ -3,11 +3,13 @@ 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 name { Beamer::DEFAULT_NAME } + config { Beamer::DEFAULT_CONFIG } initialize_with do tournament.beamers.find_or_initialize_by(name:) diff --git a/spec/models/beamer_spec.rb b/spec/models/beamer_spec.rb index 5dd4720..287a0aa 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/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 diff --git a/spec/services/tournament_live_payload_spec.rb b/spec/services/tournament_live_payload_spec.rb index 3725ed5..1e6a899 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].pluck(: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 diff --git a/spec/services/tournament_snapshot_importer_spec.rb b/spec/services/tournament_snapshot_importer_spec.rb index 52ddd34..d333306 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') @@ -82,21 +86,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:, level: -1, state: :finished) @@ -204,7 +205,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| { diff --git a/tasks.py b/tasks.py index 40adca3..64ebd2b 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): @@ -1764,6 +1785,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",