Merge remote-tracking branch 'origin/master' into codex/TUR-113-fix-rubocop

# Conflicts:
#	spec/e2e/http/api_surface_spec.rb
#	spec/e2e/http/tournament_follow_sync_beamers_websocket_spec.rb
This commit is contained in:
Daniel Schädler 2026-04-27 10:55:29 +02:00
commit 6db6442205
36 changed files with 827 additions and 92 deletions

View File

@ -54,7 +54,10 @@ RUN bundle config set with 'development test' \
FROM runtime-base AS production FROM runtime-base AS production
ARG GIT_COMMIT_SHA=aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa
ENV RAILS_ENV=production 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 /usr/local/bundle /usr/local/bundle
COPY --from=bundle-production /app/vendor/bundle /app/vendor/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 FROM runtime-base AS test
ARG GIT_COMMIT_SHA=aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa
ENV RAILS_ENV=test ENV RAILS_ENV=test
ENV GIT_COMMIT_SHA=${GIT_COMMIT_SHA}
RUN apt-get update -qq \ RUN apt-get update -qq \
&& apt-get install -y --no-install-recommends \ && apt-get install -y --no-install-recommends \

View File

@ -2,10 +2,28 @@
class TournamentMatchesChannel < ApplicationCable::Channel class TournamentMatchesChannel < ApplicationCable::Channel
def subscribed def subscribed
tournament = Tournament.find(params[:tournament_id]) @tournament_id = params[:tournament_id]
state = params[:state].presence @filters = match_filters
tournament = Tournament.find(@tournament_id)
stream_for TournamentMatchesBroadcast.stream_target(tournament, state:) stream_for TournamentMatchesBroadcast.stream_target(tournament) do
transmit TournamentMatchesBroadcastPayload.build(tournament, state:) 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
end end

View File

@ -68,7 +68,12 @@ class BeamersController < ApplicationController
end end
def beamer_params 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 end
def push_sync_if_needed! def push_sync_if_needed!

View File

@ -13,7 +13,7 @@ class MatchesController < ApplicationController
# GET/tournaments/1/matches # GET/tournaments/1/matches
def index def index
render json: TournamentMatchesPayload.build(@tournament, state: match_params['state']) render json: TournamentMatchesPayload.build(@tournament, match_index_params)
end end
# GET /matches/1 # GET /matches/1
@ -145,6 +145,10 @@ class MatchesController < ApplicationController
params.slice(:state).permit! params.slice(:state).permit!
end end
def match_index_params
params.slice(:state, :stage_id, :stage_level, :position, :group_stage_position).permit!
end
def create_match_params def create_match_params
params.slice(:team_ids).permit(team_ids: []) params.slice(:team_ids).permit(team_ids: [])
end end

View File

@ -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

View File

@ -2,15 +2,16 @@
class Beamer < ApplicationRecord class Beamer < ApplicationRecord
DEFAULT_NAME = 'default' DEFAULT_NAME = 'default'
DEFAULT_CONFIG = { 'match_filter' => 'state:in_progress' }.freeze
DISPLAY_STATES = %w[ DISPLAY_STATES = %w[
matches_all matches_in_progress matches_upcoming matches_not_started during_game between_games static_image group_tables
matches_finished groups between_games during_game
].freeze ].freeze
belongs_to :tournament belongs_to :tournament
validates :name, presence: true, uniqueness: { scope: :tournament_id } validates :name, presence: true, uniqueness: { scope: :tournament_id }
validates :display_state, presence: true, inclusion: { in: DISPLAY_STATES } validates :display_state, presence: true, inclusion: { in: DISPLAY_STATES }
validate :config_is_hash
delegate :owner, to: :tournament delegate :owner, to: :tournament
@ -46,4 +47,10 @@ class Beamer < ApplicationRecord
previous_changes.key?('id') ? 'created' : 'updated' previous_changes.key?('id') ? 'created' : 'updated'
end end
def config_is_hash
return if config.is_a?(Hash)
errors.add(:config, 'must be an object')
end
end end

View File

@ -67,16 +67,29 @@ class Tournament < ApplicationRecord
end end
def sync_configuration_is_complete def sync_configuration_is_complete
return if sync_target_url.blank? && sync_auth_token.blank? return if sync_configuration_blank?
return if sync_target_url.present? && sync_auth_token.present? return if sync_configuration_complete?
return if read_only_mode? && sync_auth_token.present? && sync_target_url.blank? return if follower_sync_token_only?
errors.add(:base, 'sync_target_url and sync_auth_token must be configured together') errors.add(:base, 'sync_target_url and sync_auth_token must be configured together')
end 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! def ensure_default_beamer!
beamers.find_or_create_by!(name: Beamer::DEFAULT_NAME) do |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
end end

View File

@ -1,5 +1,7 @@
# frozen_string_literal: true # frozen_string_literal: true
class BeamerSerializer < ApplicationSerializer class BeamerSerializer < ApplicationSerializer
attributes :id, :name, :display_state, :qr_code_url, :logo_url attributes :id, :name, :display_state, :config
delegate :config, to: :object
end end

View File

@ -19,8 +19,7 @@ class BeamerBroadcastPayload
tournament_id: beamer.tournament_id, tournament_id: beamer.tournament_id,
name: beamer.name, name: beamer.name,
display_state: beamer.display_state, display_state: beamer.display_state,
qr_code_url: beamer.qr_code_url, config: beamer.config
logo_url: beamer.logo_url
} }
end end
@ -31,8 +30,7 @@ class BeamerBroadcastPayload
id: snapshot.fetch(:id), id: snapshot.fetch(:id),
name: snapshot.fetch(:name), name: snapshot.fetch(:name),
display_state: snapshot.fetch(:display_state), display_state: snapshot.fetch(:display_state),
qr_code_url: snapshot[:qr_code_url], config: snapshot.fetch(:config)
logo_url: snapshot[:logo_url]
} }
end end
end end

View File

@ -1,27 +1,21 @@
# frozen_string_literal: true # frozen_string_literal: true
class TournamentMatchesBroadcast class TournamentMatchesBroadcast
STATES = [nil, 'upcoming', *Match.states.keys].freeze
class << self class << self
def call(tournament_id) def call(tournament_id)
tournament = load_tournament(tournament_id) tournament = load_tournament(tournament_id)
return if tournament.nil? return if tournament.nil?
STATES.each do |state| TournamentMatchesChannel.broadcast_to(
TournamentMatchesChannel.broadcast_to( stream_target(tournament),
stream_target(tournament, state:), { type: 'tournament_matches.changed', tournament_id: tournament.id }
TournamentMatchesBroadcastPayload.build(tournament, state:) )
)
end
end end
def stream_target(tournament, state:) def stream_target(tournament)
"#{tournament.to_gid_param}:matches:#{state.presence || 'all'}" "#{tournament.to_gid_param}:matches"
end end
private
def load_tournament(tournament_id) def load_tournament(tournament_id)
Tournament.includes( Tournament.includes(
stages: [ stages: [

View File

@ -2,12 +2,13 @@
class TournamentMatchesBroadcastPayload class TournamentMatchesBroadcastPayload
class << self class << self
def build(tournament, state: nil) def build(tournament, filters = {}, state: nil)
filters = TournamentMatchesPayload.normalize_filters(filters, state:)
{ {
type: 'tournament_matches.updated', type: 'tournament_matches.updated',
tournament_id: tournament.id, tournament_id: tournament.id,
state:, state: filters[:state],
matches: TournamentMatchesPayload.build(tournament, state:) matches: TournamentMatchesPayload.build(tournament, filters)
} }
end end
end end

View File

@ -2,18 +2,77 @@
class TournamentMatchesPayload class TournamentMatchesPayload
class << self class << self
def build(tournament, state: nil) def build(tournament, filters = {}, state: nil, **filter_keywords)
serialized_matches(matches_for(tournament, state:)) serialized_matches(matches_for(tournament, filters.merge(filter_keywords), state:))
end 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 tournament.matches if state.blank?
return upcoming_matches_for(tournament) if state == 'upcoming' return upcoming_matches_for(tournament) if state == 'upcoming'
tournament.matches.select { |match| match.state == state } tournament.matches.select { |match| match.state == state }
end 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) def upcoming_matches_for(tournament)
upcoming_group_matches(tournament).presence || upcoming_playoff_matches(tournament) upcoming_group_matches(tournament).presence || upcoming_playoff_matches(tournament)

View File

@ -109,8 +109,7 @@ class TournamentSnapshotImporter
beamer.name = attrs.fetch(:name) beamer.name = attrs.fetch(:name)
beamer.display_state = attrs.fetch(:display_state) beamer.display_state = attrs.fetch(:display_state)
beamer.is_default = attrs.fetch(:is_default, false) beamer.is_default = attrs.fetch(:is_default, false)
beamer.qr_code_url = attrs[:qr_code_url] beamer.config = attrs.fetch(:config, {})
beamer.logo_url = attrs[:logo_url]
beamer.save! beamer.save!
beamers_by_source_id[source_id] = beamer beamers_by_source_id[source_id] = beamer
end end

View File

@ -33,7 +33,7 @@ class TournamentSyncSchema
ignored: %w[tournament_id created_at updated_at sync_source_id].freeze ignored: %w[tournament_id created_at updated_at sync_source_id].freeze
}.freeze, }.freeze,
Beamer => { 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 ignored: %w[tournament_id created_at updated_at sync_source_id].freeze
}.freeze, }.freeze,
Stage => { Stage => {

View File

@ -39,4 +39,5 @@ Rails.application.routes.draw do
end end
resources :match_scores, only: %i[show update] resources :match_scores, only: %i[show update]
get 'healthz', to: 'health#index' get 'healthz', to: 'health#index'
get 'version', to: 'version#show'
end end

View File

@ -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

View File

@ -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

View File

@ -10,17 +10,16 @@
# #
# It's strongly recommended that you check this file into your version control system. # 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| create_table "beamers", force: :cascade do |t|
t.string "name", null: false 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.boolean "is_default", default: false
t.string "qr_code_url"
t.string "logo_url"
t.integer "tournament_id", null: false t.integer "tournament_id", null: false
t.datetime "created_at", null: false t.datetime "created_at", null: false
t.datetime "updated_at", null: false t.datetime "updated_at", null: false
t.integer "sync_source_id" 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"], name: "index_beamers_on_tournament_id_and_name", unique: true
t.index ["tournament_id"], name: "index_beamers_on_tournament_id" t.index ["tournament_id"], name: "index_beamers_on_tournament_id"
end end

View File

@ -30,9 +30,10 @@ Initial subscribe snapshot:
"beamer": { "beamer": {
"id": 34, "id": 34,
"name": "default", "name": "default",
"display_state": "matches_all", "display_state": "during_game",
"qr_code_url": null, "config": {
"logo_url": null "match_filter": "state:in_progress"
}
} }
} }
``` ```

View File

@ -61,11 +61,15 @@ module TurniereE2E
end end
end end
def subscribe_matches!(tournament_id:, state: nil) def subscribe_matches!(tournament_id:, **filters)
identifier = { identifier = {
channel: 'TournamentMatchesChannel', channel: 'TournamentMatchesChannel',
tournament_id:, 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 }.compact.to_json
subscribe!(identifier:) do |message| subscribe!(identifier:) do |message|

0
script/e2e_scenarios.rb Normal file → Executable file
View File

0
script/wait_for_http.rb Normal file → Executable file
View File

View File

@ -66,7 +66,11 @@ RSpec.describe BeamersController, type: :controller do
describe 'POST #create' do describe 'POST #create' do
let(:valid_params) 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 end
context 'as owner' do 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) post :create, params: { tournament_id: @tournament.to_param }.merge(valid_params)
body = deserialize_response response body = deserialize_response response
expect(body[:name]).to eq('new-beamer') 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 end
it 'rejects invalid display_state' do it 'rejects invalid display_state' do
@ -95,7 +100,7 @@ RSpec.describe BeamersController, type: :controller do
it 'rejects duplicate name in same tournament' do it 'rejects duplicate name in same tournament' do
post :create, params: { 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) expect(response).to have_http_status(:unprocessable_entity)
end end
@ -133,15 +138,18 @@ RSpec.describe BeamersController, type: :controller do
expect(@beamer.reload.display_state).to eq('during_game') expect(@beamer.reload.display_state).to eq('during_game')
end end
it 'updates optional fields' do it 'replaces config' do
@beamer.update!(config: { match_filter: 'state:upcoming', image_key: 'old-logo' })
patch :update, params: { patch :update, params: {
tournament_id: @tournament.to_param, id: @beamer.to_param, 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 expect(response).to be_successful
body = deserialize_response response
expect(body[:config]).to eq(image_key: 'bpwstr_logo')
@beamer.reload @beamer.reload
expect(@beamer.qr_code_url).to eq('https://example.com/qr') expect(@beamer.config).to eq('image_key' => 'bpwstr_logo')
expect(@beamer.logo_url).to eq('/static/logo.png')
end end
it 'rejects invalid display_state' do it 'rejects invalid display_state' do
@ -159,7 +167,7 @@ RSpec.describe BeamersController, type: :controller do
it 'returns forbidden' do it 'returns forbidden' do
patch :update, params: { patch :update, params: {
tournament_id: @tournament.to_param, id: @beamer.to_param, tournament_id: @tournament.to_param, id: @beamer.to_param,
display_state: 'groups' display_state: 'group_tables'
} }
expect(response).to have_http_status(:forbidden) expect(response).to have_http_status(:forbidden)
end end
@ -174,7 +182,7 @@ RSpec.describe BeamersController, type: :controller do
it 'returns locked' do it 'returns locked' do
patch :update, params: { patch :update, params: {
tournament_id: @tournament.to_param, id: @beamer.to_param, tournament_id: @tournament.to_param, id: @beamer.to_param,
display_state: 'groups' display_state: 'group_tables'
} }
expect(response).to have_http_status(:locked) expect(response).to have_http_status(:locked)
end end

View File

@ -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.keys).to match_array(%i[id position state teams match_scores group stage])
expect(match[:teams]).to all(include(:id, :name)) expect(match[:teams]).to all(include(:id, :name))
end 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 end
# TODO: add test for upcoming once there is test data for a "valid" group stage # TODO: add test for upcoming once there is test data for a "valid" group stage
end end

View File

@ -29,6 +29,13 @@ RSpec.describe 'Backend API surface HTTP E2E' do
expect(response.dig(:json, :errors)).to eq([]) expect(response.dig(:json, :errors)).to eq([])
end 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 it 'supports registration for new users and still requires confirmation for login' do
unique = unique_suffix unique = unique_suffix
email = "http-e2e-#{unique}@example.com" email = "http-e2e-#{unique}@example.com"
@ -206,6 +213,14 @@ RSpec.describe 'Backend API surface HTTP E2E' do
params: { state: 'not_started' }) params: { state: 'not_started' })
matches_upcoming = anonymous_client.get("/tournaments/#{tournament.fetch(:id)}/matches", matches_upcoming = anonymous_client.get("/tournaments/#{tournament.fetch(:id)}/matches",
params: { state: 'upcoming' }) 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)}") match_show = anonymous_client.get("/matches/#{match.fetch(:id)}")
invalid_match_update = owner_client.patch("/matches/#{match.fetch(:id)}", body: { state: 'bogus' }) 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' }) 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_not_started[:json].pluck(:id)).to include(match.fetch(:id))
expect(matches_upcoming[:status]).to eq(200) expect(matches_upcoming[:status]).to eq(200)
expect(matches_upcoming[:json].pluck(:id)).to include(match.fetch(:id)) 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(match_show[:status]).to eq(200)
expect(invalid_match_update[:status]).to eq(422) expect(invalid_match_update[:status]).to eq(422)
expect(invalid_match_update.dig(:json, :error)).to eq('Invalid target state') expect(invalid_match_update.dig(:json, :error)).to eq('Invalid target state')

View File

@ -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

View File

@ -26,7 +26,10 @@ RSpec.describe 'Tournament beamer websocket HTTP E2E' do
tournament = create_tournament(name_prefix: 'Ws Beamer') tournament = create_tournament(name_prefix: 'Ws Beamer')
create_beamer = client.post("/tournaments/#{tournament.fetch(:id)}/beamers", body: { create_beamer = client.post("/tournaments/#{tournament.fetch(:id)}/beamers", body: {
name: 'projector-a', name: 'projector-a',
display_state: 'matches_all' display_state: 'during_game',
config: {
match_filter: 'state:in_progress'
}
}) })
expect(create_beamer[:status]).to eq(201) expect(create_beamer[:status]).to eq(201)
beamer = create_beamer.fetch(:json) 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! initial_payload = cable_client.wait_for_beamer_payload!
expect(initial_payload.fetch('type')).to eq('beamer.snapshot') 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', '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: { update = client.patch("/tournaments/#{tournament.fetch(:id)}/beamers/#{beamer.fetch(:id)}", body: {
display_state: 'during_game', display_state: 'static_image',
qr_code_url: 'https://example.com/qr', config: {
logo_url: '/static/logo.png' image_key: 'bpwstr_logo'
}
}) })
expect(update[:status]).to eq(200) expect(update[:status]).to eq(200)
updated_payload = wait_for_beamer_payload!( updated_payload = wait_for_beamer_payload!(
cable_client, cable_client,
event_type: 'beamer.updated', 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', 'config')).to eq('image_key' => 'bpwstr_logo')
expect(updated_payload.dig('beamer', 'logo_url')).to eq('/static/logo.png') 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)}") delete = client.delete("/tournaments/#{tournament.fetch(:id)}/beamers/#{beamer.fetch(:id)}")
expect(delete[:status]).to eq(204) expect(delete[:status]).to eq(204)
@ -60,7 +66,7 @@ RSpec.describe 'Tournament beamer websocket HTTP E2E' do
deleted_payload = wait_for_beamer_payload!( deleted_payload = wait_for_beamer_payload!(
cable_client, cable_client,
event_type: 'beamer.deleted', event_type: 'beamer.deleted',
display_state: 'during_game' display_state: 'static_image'
) )
expect(deleted_payload.dig('beamer', 'name')).to eq('projector-a') expect(deleted_payload.dig('beamer', 'name')).to eq('projector-a')
ensure ensure

View File

@ -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

View File

@ -32,7 +32,7 @@ RSpec.describe 'Tournament follower sync beamer websocket HTTP E2E' do
follower = create_follower_tournament(name_prefix: 'Ws Beamer Follower') follower = create_follower_tournament(name_prefix: 'Ws Beamer Follower')
configure_sync = source_client.patch("/tournaments/#{source.fetch(:id)}", body: { 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 sync_auth_token: sync_token
}) })
expect(configure_sync[:status]).to eq(200) 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)) follower_default_beamer = wait_for_default_beamer!(tournament_id: follower.fetch(:id))
cable_client = TurniereE2E::ActionCableClient.new(base_url: follower_base_url) cable_client = TurniereE2E::ActionCableClient.new(base_url: follower_base_url)
cable_client.subscribe_beamer!(tournament_id: follower.fetch(:id), cable_client.subscribe_beamer!(
beamer_name: follower_default_beamer.fetch(:name)) tournament_id: follower.fetch(:id),
beamer_name: follower_default_beamer.fetch(:name)
)
cable_client.wait_for_beamer_payload! cable_client.wait_for_beamer_payload!
source_default_beamer = fetch_beamer(base_url: source_base_url, tournament_id: source.fetch(:id), source_default_beamer = fetch_beamer(
beamer_name: DEFAULT_BEAMER_NAME) base_url: source_base_url,
update = source_client.patch("/tournaments/#{source.fetch(:id)}/beamers/#{source_default_beamer.fetch(:id)}", body: { tournament_id: source.fetch(:id),
display_state: 'groups' 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) expect(update[:status]).to eq(200)
follower_payload = wait_for_beamer_payload!( follower_payload = wait_for_beamer_payload!(
cable_client, cable_client,
event_type: 'beamer.updated', 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', 'name')).to eq(DEFAULT_BEAMER_NAME)
expect(follower_payload.dig('beamer', 'config')).to eq('image_key' => 'bpwstr_logo')
ensure ensure
cable_client&.close cable_client&.close
end end
@ -101,6 +113,10 @@ RSpec.describe 'Tournament follower sync beamer websocket HTTP E2E' do
response.fetch(:json) response.fetch(:json)
end 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:) def fetch_beamer(base_url:, tournament_id:, beamer_name:)
client = TurniereE2E::ApiClient.new(base_url:) client = TurniereE2E::ApiClient.new(base_url:)
response = client.get("/tournaments/#{tournament_id}/beamers/by_name", params: { name: beamer_name }) response = client.get("/tournaments/#{tournament_id}/beamers/by_name", params: { name: beamer_name })

View File

@ -29,8 +29,20 @@ RSpec.describe 'Tournament live websocket HTTP E2E' do
tournament_cable = TurniereE2E::ActionCableClient.new(base_url:) tournament_cable = TurniereE2E::ActionCableClient.new(base_url:)
matches_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)) tournament_cable.subscribe_tournament!(tournament_id: tournament.fetch(:id))
matches_cable.subscribe_matches!(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( expect(tournament_cable.wait_for_tournament_payload!.fetch('tournament')).to(
eq(json_for_api(fetch_tournament(tournament.fetch(:id)))) 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( expect(matches_cable.wait_for_matches_payload!.fetch('matches')).to(
eq(json_for_api(fetch_matches(tournament.fetch(:id)))) 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) 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') 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)))) 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 ensure
tournament_cable&.close tournament_cable&.close
matches_cable&.close matches_cable&.close
filtered_matches_cable&.close
stage_matches_cable&.close
end end
def login_client def login_client
@ -111,8 +149,8 @@ RSpec.describe 'Tournament live websocket HTTP E2E' do
response.fetch(:json) response.fetch(:json)
end end
def fetch_matches(tournament_id) def fetch_matches(tournament_id, params: {})
response = TurniereE2E::ApiClient.new(base_url:).get("/tournaments/#{tournament_id}/matches") response = TurniereE2E::ApiClient.new(base_url:).get("/tournaments/#{tournament_id}/matches", params:)
expect(response[:status]).to eq(200) expect(response[:status]).to eq(200)
response.fetch(:json) response.fetch(:json)
end end

View File

@ -3,11 +3,13 @@
FactoryBot.define do FactoryBot.define do
factory :beamer do factory :beamer do
sequence(:name) { |n| "beamer-#{n}" } sequence(:name) { |n| "beamer-#{n}" }
display_state { 'matches_all' } display_state { 'during_game' }
config { {} }
tournament tournament
factory :default_beamer do factory :default_beamer do
name { Beamer::DEFAULT_NAME } name { Beamer::DEFAULT_NAME }
config { Beamer::DEFAULT_CONFIG }
initialize_with do initialize_with do
tournament.beamers.find_or_initialize_by(name:) tournament.beamers.find_or_initialize_by(name:)

View File

@ -24,6 +24,12 @@ RSpec.describe Beamer, type: :model do
expect(beamer).not_to be_valid expect(beamer).not_to be_valid
end 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| Beamer::DISPLAY_STATES.each do |state|
it "accepts display_state '#{state}'" do it "accepts display_state '#{state}'" do
beamer = build(:beamer, display_state: state) beamer = build(:beamer, display_state: state)
@ -54,10 +60,15 @@ RSpec.describe Beamer, type: :model do
expect(args.fetch(:beamer_names)).to eq(['projector']) expect(args.fetch(:beamer_names)).to eq(['projector'])
end 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[: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 end
it 'broadcasts update payload to previous and current beamer names when renamed' do 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') expect(args.fetch(:beamer_names)).to contain_exactly('old-name', 'new-name')
end 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[:type]).to eq('beamer.updated')
expect(payload.dig(:beamer, :name)).to eq('new-name') expect(payload.dig(:beamer, :name)).to eq('new-name')
expect(payload.dig(:beamer, :display_state)).to eq('during_game') expect(payload.dig(:beamer, :display_state)).to eq('during_game')
expect(payload.dig(:beamer, :config)).to eq('image_key' => 'bpwstr_logo')
end end
it 'broadcasts delete payload with last persisted snapshot' do 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 payload = nil
expect(BeamerBroadcast).to receive(:call) do |args| 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[:type]).to eq('beamer.deleted')
expect(payload.dig(:beamer, :name)).to eq('projector') expect(payload.dig(:beamer, :name)).to eq('projector')
expect(payload.dig(:beamer, :display_state)).to eq('between_games') 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 end
end end

View File

@ -35,8 +35,11 @@ RSpec.describe Tournament, type: :model do
describe 'default beamer' do describe 'default beamer' do
it 'creates a default beamer automatically' do it 'creates a default beamer automatically' do
tournament = create(:tournament) tournament = create(:tournament)
default_beamer = tournament.beamers.find_by!(name: Beamer::DEFAULT_NAME)
expect(tournament.beamers.pluck(:name)).to include(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
end end

View File

@ -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[: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)) expect(serialized_match[:match_scores].map { |score| score[:team][:id] }).to match_array(match.teams.map(&:id))
end 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 end
describe TournamentBroadcastPayload do describe TournamentBroadcastPayload do

View File

@ -6,7 +6,11 @@ RSpec.describe TournamentSnapshotImporter do
describe '.import!' do describe '.import!' do
it 'replaces follower tournament graph using source ids as stable mapping keys' 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 = 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) list = create(:team_action_list, tournament: source)
create(:team_action_item, team_action_list: list, team: source.teams.first, completed: true) 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') 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 intermediate_round_participants_amount: 0
) )
tournament.beamers.find_by!(name: Beamer::DEFAULT_NAME).update!( tournament.beamers.find_by!(name: Beamer::DEFAULT_NAME).update!(
display_state: 'groups', display_state: 'group_tables',
qr_code_url: 'https://example.com/default-qr', config: { match_filter: 'state:in_progress' }
logo_url: '/logos/default.png'
) )
tournament.beamers.create!( tournament.beamers.create!(
name: 'stream-a', name: 'stream-a',
display_state: 'during_game', display_state: 'during_game',
qr_code_url: 'https://example.com/stream-a', config: { match_filter: 'state:in_progress' }
logo_url: '/logos/stream-a.png'
) )
tournament.beamers.create!( tournament.beamers.create!(
name: 'stream-b', name: 'stream-b',
display_state: 'between_games', display_state: 'between_games',
qr_code_url: 'https://example.com/stream-b', config: { image_key: 'bpwstr_logo' }
logo_url: '/logos/stream-b.png'
) )
group_stage = Stage.create!(tournament:, level: -1, state: :finished) group_stage = Stage.create!(tournament:, level: -1, state: :finished)
@ -204,7 +205,7 @@ RSpec.describe TournamentSnapshotImporter do
} }
end, end,
beamers: tournament.beamers.order(:name).map do |beamer| 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, end,
stages: tournament.stages.order(level: :asc).map do |stage| stages: tournament.stages.order(level: :asc).map do |stage|
{ {

View File

@ -235,6 +235,13 @@ def _format_command(command):
return " ".join(shlex.quote(part) for part in 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): def _docker_build_command(tag, target, platforms=None, push=False):
if platforms and "," in str(platforms) and not push: if platforms and "," in str(platforms) and not push:
raise ValueError("multi-platform buildx builds require push=True") raise ValueError("multi-platform buildx builds require push=True")
@ -246,6 +253,8 @@ def _docker_build_command(tag, target, platforms=None, push=False):
"build", "build",
"--target", "--target",
target, target,
"--build-arg",
f"GIT_COMMIT_SHA={_git_commit_sha()}",
"-t", "-t",
tag, tag,
"-f", "-f",
@ -257,7 +266,19 @@ def _docker_build_command(tag, target, platforms=None, push=False):
command.append(".") command.append(".")
return _format_command(command) 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): def _rspec_command(*paths):
@ -1764,6 +1785,7 @@ def blackbox_follow_service(
alt_username, alt_username,
spec_path=[ spec_path=[
"spec/e2e/http/tournament_follow_sync_spec.rb", "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_websocket_spec.rb",
"spec/e2e/http/tournament_follow_sync_live_websocket_spec.rb", "spec/e2e/http/tournament_follow_sync_live_websocket_spec.rb",
"spec/e2e/http/tournament_timer_follow_sync_websocket_spec.rb", "spec/e2e/http/tournament_timer_follow_sync_websocket_spec.rb",