Merge branch 'codex/tur-78-beamer-fixes' into 'master'

TUR-78: Beamer fixes

See merge request turniere/turniere-backend!63
This commit is contained in:
Daniel Schädler 2026-04-26 22:06:40 +00:00
commit 10f1a56734
8 changed files with 107 additions and 47 deletions

View File

@ -2,6 +2,7 @@
class Beamer < ApplicationRecord
DEFAULT_NAME = 'default'
DEFAULT_CONFIG = { 'match_filter' => 'state:in_progress' }.freeze
DISPLAY_STATES = %w[
during_game between_games static_image group_tables
].freeze

View File

@ -67,27 +67,32 @@ class Tournament < ApplicationRecord
end
def sync_configuration_is_complete
return if sync_target_url.blank? && sync_auth_token.blank?
return if sync_target_url.present? && sync_auth_token.present?
return if read_only_mode? && sync_auth_token.present? && sync_target_url.blank?
return if sync_configuration_blank?
return if sync_configuration_complete?
return if follower_sync_token_only?
errors.add(:base, 'sync_target_url and sync_auth_token must be configured together')
end
def sync_configuration_blank?
sync_target_url.blank? && sync_auth_token.blank?
end
def sync_configuration_complete?
sync_target_url.present? && sync_auth_token.present?
end
def follower_sync_token_only?
read_only_mode? && sync_auth_token.present? && sync_target_url.blank?
end
def ensure_default_beamer!
beamers.find_or_create_by!(name: Beamer::DEFAULT_NAME) do |beamer|
beamer.display_state = 'during_game'
beamer.config = Beamer::DEFAULT_CONFIG
end
end
def sync_configuration_is_complete
return if sync_target_url.blank? && sync_auth_token.blank?
return if sync_target_url.present? && sync_auth_token.present?
return if read_only_mode? && sync_auth_token.present? && sync_target_url.blank?
errors.add(:base, 'sync_target_url and sync_auth_token must be configured together')
end
def clear_follower_sync_token_when_disabling_read_only_mode
return if read_only_mode?
return if sync_target_url.present?

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,7 +10,7 @@
#
# It's strongly recommended that you check this file into your version control system.
ActiveRecord::Schema[7.0].define(version: 2026_04_26_120000) do
ActiveRecord::Schema[7.0].define(version: 2026_04_26_121000) do
create_table "beamers", force: :cascade do |t|
t.string "name", null: false
t.string "display_state", default: "during_game", null: false

View File

@ -38,20 +38,26 @@ RSpec.describe 'Tournament beamer HTTP E2E' do
expect(default_beamer[:status]).to eq(200)
expect(default_beamer.dig(:json, :name)).to eq('default')
expect(default_beamer.dig(:json, :display_state)).to eq('matches_all')
expect(default_beamer.dig(:json, :display_state)).to eq('during_game')
expect(default_beamer.dig(:json, :config)).to eq(match_filter: 'state:in_progress')
expect(default_show[:status]).to eq(200)
expect(default_show.dig(:json, :config)).to eq(match_filter: 'state:in_progress')
create_response = owner_client.post("/tournaments/#{tournament.fetch(:id)}/beamers", body: {
name: 'hall-a',
display_state: 'groups',
qr_code_url: 'https://example.com/hall-a',
logo_url: '/logos/hall-a.png'
display_state: 'group_tables',
config: {
match_filter: 'stage:groups',
table_view: 'compact'
}
})
expect(create_response[:status]).to eq(201)
beamer = create_response.fetch(:json)
expect(beamer.fetch(:name)).to eq('hall-a')
expect(beamer.fetch(:display_state)).to eq('groups')
expect(beamer.fetch(:qr_code_url)).to eq('https://example.com/hall-a')
expect(beamer.fetch(:display_state)).to eq('group_tables')
expect(beamer.fetch(:config)).to eq(match_filter: 'stage:groups', table_view: 'compact')
expect(beamer).not_to have_key(:qr_code_url)
expect(beamer).not_to have_key(:logo_url)
index_response = anonymous_client.get("/tournaments/#{tournament.fetch(:id)}/beamers")
show_response = anonymous_client.get("/tournaments/#{tournament.fetch(:id)}/beamers/#{beamer.fetch(:id)}")
@ -65,34 +71,41 @@ RSpec.describe 'Tournament beamer HTTP E2E' do
expect(by_name_response.dig(:json, :id)).to eq(beamer.fetch(:id))
update_response = owner_client.patch("/tournaments/#{tournament.fetch(:id)}/beamers/#{beamer.fetch(:id)}", body: {
display_state: 'during_game',
qr_code_url: 'https://example.com/live',
logo_url: '/logos/live.png'
display_state: 'static_image',
config: {
image_key: 'bpwstr_logo'
}
})
expect(update_response[:status]).to eq(200)
expect(update_response.dig(:json, :display_state)).to eq('during_game')
expect(update_response.dig(:json, :qr_code_url)).to eq('https://example.com/live')
expect(update_response.dig(:json, :logo_url)).to eq('/logos/live.png')
expect(update_response.dig(:json, :display_state)).to eq('static_image')
expect(update_response.dig(:json, :config)).to eq(image_key: 'bpwstr_logo')
expect(update_response[:json]).not_to have_key(:qr_code_url)
expect(update_response[:json]).not_to have_key(:logo_url)
invalid_update = owner_client.patch("/tournaments/#{tournament.fetch(:id)}/beamers/#{beamer.fetch(:id)}", body: {
display_state: 'bogus'
})
invalid_config_update = owner_client.patch(
"/tournaments/#{tournament.fetch(:id)}/beamers/#{beamer.fetch(:id)}",
body: { config: ['not-an-object'] }
)
duplicate_create = owner_client.post("/tournaments/#{tournament.fetch(:id)}/beamers", body: {
name: 'hall-a',
display_state: 'matches_all'
display_state: 'during_game'
})
forbidden_update = other_client.patch("/tournaments/#{tournament.fetch(:id)}/beamers/#{beamer.fetch(:id)}", body: {
display_state: 'groups'
display_state: 'group_tables'
})
unauthenticated_create = anonymous_client.post(
"/tournaments/#{tournament.fetch(:id)}/beamers",
body: {
name: 'anon',
display_state: 'groups'
display_state: 'group_tables'
}
)
expect(invalid_update[:status]).to eq(422)
expect(invalid_config_update[:status]).to eq(422)
expect(duplicate_create[:status]).to eq(422)
expect(forbidden_update[:status]).to eq(403)
expect(unauthenticated_create[:status]).to eq(401)
@ -108,7 +121,7 @@ RSpec.describe 'Tournament beamer HTTP E2E' do
)
locked_update = owner_client.patch(
beamer_path(read_only_tournament.fetch(:id), read_only_beamer.dig(:json, :id)),
body: { display_state: 'groups' }
body: { display_state: 'group_tables' }
)
expect(locked_update[:status]).to eq(423)

View File

@ -41,7 +41,8 @@ RSpec.describe 'Tournament follower sync beamer HTTP E2E' do
follower_default = wait_for_follower_beamer!(
tournament_id: follower.fetch(:id),
beamer_name: 'default',
display_state: source_default.fetch(:display_state)
display_state: source_default.fetch(:display_state),
config: source_default.fetch(:config)
)
expect(follower_default.fetch(:name)).to eq('default')
@ -49,8 +50,10 @@ RSpec.describe 'Tournament follower sync beamer HTTP E2E' do
beamer_path(source.fetch(:id), source_default.fetch(:id)),
body: {
display_state: 'between_games',
qr_code_url: 'https://example.com/leader-default',
logo_url: '/logos/default.png'
config: {
match_filter: 'state:queued',
rotation_seconds: 10
}
}
)
expect(leader_default_update[:status]).to eq(200)
@ -60,16 +63,22 @@ RSpec.describe 'Tournament follower sync beamer HTTP E2E' do
tournament_id: follower.fetch(:id),
beamer_name: 'default',
display_state: 'between_games',
qr_code_url: 'https://example.com/leader-default',
logo_url: '/logos/default.png'
config: {
match_filter: 'state:queued',
rotation_seconds: 10
}
)
expect(mirrored_default.fetch(:name)).to eq('default')
expect(mirrored_default).not_to have_key(:qr_code_url)
expect(mirrored_default).not_to have_key(:logo_url)
leader_create = source_client.post("/tournaments/#{source.fetch(:id)}/beamers", body: {
name: 'stage-left',
display_state: 'groups',
qr_code_url: 'https://example.com/stage-left',
logo_url: '/logos/stage-left.png'
display_state: 'group_tables',
config: {
match_filter: 'stage:groups',
table_view: 'compact'
}
})
expect(leader_create[:status]).to eq(201)
sync_now_if_requested!(source.fetch(:id))
@ -77,17 +86,23 @@ RSpec.describe 'Tournament follower sync beamer HTTP E2E' do
mirrored_created = wait_for_follower_beamer!(
tournament_id: follower.fetch(:id),
beamer_name: 'stage-left',
display_state: 'groups',
qr_code_url: 'https://example.com/stage-left',
logo_url: '/logos/stage-left.png'
display_state: 'group_tables',
config: {
match_filter: 'stage:groups',
table_view: 'compact'
}
)
expect(mirrored_created.fetch(:name)).to eq('stage-left')
expect(mirrored_created).not_to have_key(:qr_code_url)
expect(mirrored_created).not_to have_key(:logo_url)
leader_update = source_client.patch(
beamer_path(source.fetch(:id), leader_create.dig(:json, :id)),
body: {
display_state: 'during_game',
qr_code_url: 'https://example.com/stage-left-live'
display_state: 'static_image',
config: {
image_key: 'stage-left-live'
}
}
)
expect(leader_update[:status]).to eq(200)
@ -96,14 +111,17 @@ RSpec.describe 'Tournament follower sync beamer HTTP E2E' do
mirrored_updated = wait_for_follower_beamer!(
tournament_id: follower.fetch(:id),
beamer_name: 'stage-left',
display_state: 'during_game',
qr_code_url: 'https://example.com/stage-left-live'
display_state: 'static_image',
config: {
image_key: 'stage-left-live'
}
)
expect(mirrored_updated.fetch(:logo_url)).to eq('/logos/stage-left.png')
expect(mirrored_updated).not_to have_key(:qr_code_url)
expect(mirrored_updated).not_to have_key(:logo_url)
locked_follower_update = follower_client.patch(
beamer_path(follower.fetch(:id), mirrored_updated.fetch(:id)),
body: { display_state: 'matches_finished' }
body: { display_state: 'during_game' }
)
expect(locked_follower_update[:status]).to eq(423)
@ -150,7 +168,7 @@ RSpec.describe 'Tournament follower sync beamer HTTP E2E' do
response = follower_read_client.get(beamer_by_name_path(tournament_id), params: { name: beamer_name })
if response[:status] == 200 && response.dig(:json, :name) == beamer_name
beamer = response.fetch(:json)
return beamer if expected_attrs.all? { |key, value| beamer.fetch(key) == value }
return beamer if expected_attrs.all? { |key, value| beamer[key] == value }
end
raise "follower beamer #{beamer_name} not synced within #{timeout}s" if Time.now.utc >= deadline

View File

@ -9,9 +9,10 @@ FactoryBot.define do
factory :default_beamer do
name { Beamer::DEFAULT_NAME }
config { Beamer::DEFAULT_CONFIG }
initialize_with do
tournament.beamers.find_or_initialize_by(name: name)
tournament.beamers.find_or_initialize_by(name:)
end
end
end

View File

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