Merge branch 'codex/tur-119-beamer-config' into 'master'

TUR-119 Beamer config cleanup

See merge request turniere/turniere-backend!61
This commit is contained in:
Daniel Schädler 2026-04-26 20:20:44 +00:00
commit d3176e6968
18 changed files with 340 additions and 64 deletions

3
.gitignore vendored
View File

@ -26,9 +26,6 @@
# IDEA # IDEA
.idea/ .idea/
# database schema
db/schema.rb
#Ignore Coverage #Ignore Coverage
coverage/** coverage/**

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

@ -3,14 +3,14 @@
class Beamer < ApplicationRecord class Beamer < ApplicationRecord
DEFAULT_NAME = 'default' DEFAULT_NAME = 'default'
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
@ -34,7 +34,7 @@ class Beamer < ApplicationRecord
BeamerBroadcast.call( BeamerBroadcast.call(
tournament_id: snapshot.fetch(:tournament_id), tournament_id: snapshot.fetch(:tournament_id),
beamer_names: [@broadcast_previous_name, snapshot.fetch(:name)].compact.uniq, beamer_names: [@broadcast_previous_name, snapshot.fetch(:name)].compact.uniq,
payload: BeamerBroadcastPayload.build(snapshot: snapshot, event: broadcast_event) payload: BeamerBroadcastPayload.build(snapshot:, event: broadcast_event)
) )
ensure ensure
@broadcast_previous_name = nil @broadcast_previous_name = nil
@ -46,4 +46,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

@ -76,7 +76,7 @@ class Tournament < ApplicationRecord
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'
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

@ -2,12 +2,12 @@
class BeamerBroadcastPayload class BeamerBroadcastPayload
class << self class << self
def build(beamer: nil, snapshot: nil, event:) def build(event:, beamer: nil, snapshot: nil)
data = snapshot || self.snapshot(beamer) data = snapshot || self.snapshot(beamer)
{ {
type: "beamer.#{event}", type: "beamer.#{event}",
event: event, event:,
tournament_id: data.fetch(:tournament_id), tournament_id: data.fetch(:tournament_id),
beamer: serialized_beamer(data) beamer: serialized_beamer(data)
} }
@ -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

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

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

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

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

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

@ -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,21 +40,35 @@ RSpec.describe 'Tournament follower sync beamer websocket HTTP E2E' do
follower_default_beamer = wait_for_default_beamer!(tournament_id: follower.fetch(:id)) 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), 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! 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) source_default_beamer = fetch_beamer(
update = source_client.patch("/tournaments/#{source.fetch(:id)}/beamers/#{source_default_beamer.fetch(:id)}", body: { base_url: source_base_url,
display_state: 'groups' 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) 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
@ -99,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

@ -3,7 +3,8 @@
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

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

@ -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')
@ -78,21 +82,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: tournament, level: -1, state: :finished) group_stage = Stage.create!(tournament: tournament, level: -1, state: :finished)
@ -202,7 +203,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|
{ {

193
tasks.py
View File

@ -54,6 +54,9 @@ DB_DIR = ROOT / "db"
GEMFILE_LOCK_PATH = ROOT / "Gemfile.lock" GEMFILE_LOCK_PATH = ROOT / "Gemfile.lock"
BUNDLER_VERSION = GEMFILE_LOCK_PATH.read_text(encoding="utf-8").split("BUNDLED WITH", 1)[1].strip().splitlines()[-1].strip() BUNDLER_VERSION = GEMFILE_LOCK_PATH.read_text(encoding="utf-8").split("BUNDLED WITH", 1)[1].strip().splitlines()[-1].strip()
MANUAL_TEST_DB_PATH = DB_DIR / "funky_manual_tournament_lab_do_not_delete.sqlite3"
MANUAL_TEST_PORT = "3010"
MANUAL_TEST_BASE_URL = f"http://127.0.0.1:{MANUAL_TEST_PORT}"
def _env(**overrides): def _env(**overrides):
env = os.environ.copy() env = os.environ.copy()
@ -331,6 +334,43 @@ def _bootstrap_user_command(email, password, username):
) )
def _manual_test_env():
return _env(
RAILS_ENV="test",
DATABASE_URL=f"sqlite3:{MANUAL_TEST_DB_PATH}",
)
def _e2e_seed_scenarios():
return [
(E2E_EMAIL, E2E_PASSWORD, E2E_USERNAME, "group_stage_main_usecase", "created"),
(E2E_EMAIL, E2E_PASSWORD, E2E_USERNAME, "group_stage_main_usecase", "final_finish_attempted"),
(E2E_EMAIL, E2E_PASSWORD, E2E_USERNAME, "group_stage_intermediate_round", "completed"),
(E2E_EMAIL, E2E_PASSWORD, E2E_USERNAME, "playoff_only", "completed"),
(E2E_ALT_EMAIL, E2E_ALT_PASSWORD, E2E_ALT_USERNAME, "group_stage_main_usecase", "created"),
(E2E_ALT_EMAIL, E2E_ALT_PASSWORD, E2E_ALT_USERNAME, "playoff_only", "completed"),
]
def _seed_via_e2e_scenarios(c, base_url, base_env=None):
base_env = base_env or {}
for email, password, username, scenario, stop_at in _e2e_seed_scenarios():
_print_header(f"Seeding {scenario} as {email} until {stop_at}")
c.run(
f"ruby script/e2e_scenarios.rb {scenario} --stop-at {stop_at}",
env=_env(
**base_env,
TURNIERE_E2E_BASE_URL=base_url,
TURNIERE_E2E_EMAIL=email,
TURNIERE_E2E_PASSWORD=password,
TURNIERE_E2E_USERNAME=username,
),
pty=True,
)
def _parse_rspec_report(output): def _parse_rspec_report(output):
summary_match = re.search(r"(\d+)\s+examples?,\s+(\d+)\s+failures?(?:,\s+(\d+)\s+pending)?", output) summary_match = re.search(r"(\d+)\s+examples?,\s+(\d+)\s+failures?(?:,\s+(\d+)\s+pending)?", output)
failed_examples = re.findall(r"^\s*rspec\s+(.+)$", output, flags=re.MULTILINE) failed_examples = re.findall(r"^\s*rspec\s+(.+)$", output, flags=re.MULTILINE)
@ -1843,3 +1883,156 @@ def docker_test_shard(c, node_index=1, node_total=8, image=TEST_TAG, build=True)
f"inv test-shard --node-index={node_index} --node-total={node_total}\"" f"inv test-shard --node-index={node_index} --node-total={node_total}\""
) )
c.run(command, pty=True) c.run(command, pty=True)
@task(name="manual-db-migrate")
def manual_db_migrate(c):
"""Migrate the funky manual test database without touching db/test.sqlite3."""
setup(c)
c.run(
_bundle_exec("rails", "db:migrate"),
env=_manual_test_env(),
pty=True,
)
@task(name="manual-db-seed")
def manual_db_seed(c, reset=False):
"""Fill the funky manual test DB with reusable E2E scenario data."""
setup(c)
if reset:
for suffix in ("", "-shm", "-wal", ".journal"):
candidate = Path(f"{MANUAL_TEST_DB_PATH}{suffix}")
if candidate.exists():
candidate.unlink()
manual_db_migrate(c)
c.run(
_bootstrap_user_command(E2E_EMAIL, E2E_PASSWORD, E2E_USERNAME),
env=_manual_test_env(),
pty=True,
)
c.run(
_bootstrap_user_command(E2E_ALT_EMAIL, E2E_ALT_PASSWORD, E2E_ALT_USERNAME),
env=_manual_test_env(),
pty=True,
)
process, log_handle = _start_server(
f"{_bundle_exec('rails', 's')} -e test -b 127.0.0.1 -p {MANUAL_TEST_PORT}",
_manual_test_env(),
log_path="/tmp/turniere-manual-db-seed-server.log",
)
try:
_wait_for_http(base_url=MANUAL_TEST_BASE_URL, timeout=60)
_seed_via_e2e_scenarios(c, MANUAL_TEST_BASE_URL, base_env=_manual_test_env())
print(f"Manual DB ready: {MANUAL_TEST_DB_PATH}")
finally:
_stop_process(process, log_handle)
@task(name="manual-server")
def manual_server(c, host=SERVER_HOST, port=MANUAL_TEST_PORT):
"""Run local Rails test server against the funky manual DB."""
manual_db_migrate(c)
c.run(
f"{_bundle_exec('rails', 's')} -e test -b {host} -p {port}",
env=_manual_test_env(),
pty=True,
)
@task(name="docker-blackbox-follow-seed-leader")
def docker_blackbox_follow_seed_leader(
c,
source_host_port=BLACKBOX_SOURCE_HOST_PORT,
follower_host_port=BLACKBOX_FOLLOWER_HOST_PORT,
app_image=PRODUCTION_TAG,
runner_image=TEST_TAG,
postgres_image=BLACKBOX_POSTGRES_IMAGE,
):
"""Seed the running source/leader Postgres app with reusable E2E scenario data."""
env = _follow_compose_env(
source_host_port=source_host_port,
follower_host_port=follower_host_port,
app_image=app_image,
runner_image=runner_image,
postgres_image=postgres_image,
)
_print_header("Seeding source/leader Postgres through HTTP E2E scenarios")
for email, password, username, scenario, stop_at in _e2e_seed_scenarios():
_print_header(f"Seeding leader {scenario} as {email} until {stop_at}")
command = [
"run",
"--rm",
"-e",
f"TURNIERE_E2E_BASE_URL={BLACKBOX_SOURCE_INTERNAL_BASE_URL}",
"-e",
f"TURNIERE_E2E_EMAIL={email}",
"-e",
f"TURNIERE_E2E_PASSWORD={password}",
"-e",
f"TURNIERE_E2E_USERNAME={username}",
"e2e",
"ruby",
"script/e2e_scenarios.rb",
scenario,
"--stop-at",
stop_at,
]
full_command = _docker_compose_cmd(
project_name=BLACKBOX_FOLLOW_PROJECT,
compose_file=BLACKBOX_FOLLOW_COMPOSE_FILE,
) + command
print(_format_command(full_command), flush=True)
result = _stream_subprocess(full_command, env=env, check=False)
if result.returncode != 0:
raise subprocess.CalledProcessError(
result.returncode,
result.args,
output=result.stdout,
stderr=result.stderr,
)
@task(name="docker-blackbox-follow-manual-seeded-up")
def docker_blackbox_follow_manual_seeded_up(
c,
source_host_port=BLACKBOX_SOURCE_HOST_PORT,
follower_host_port=BLACKBOX_FOLLOWER_HOST_PORT,
app_image=PRODUCTION_TAG,
runner_image=TEST_TAG,
postgres_image=BLACKBOX_POSTGRES_IMAGE,
build=True,
):
"""Boot source/follower production stack, migrate/bootstrap users, then seed leader Postgres."""
docker_blackbox_follow_manual_up(
c,
source_host_port=source_host_port,
follower_host_port=follower_host_port,
app_image=app_image,
runner_image=runner_image,
postgres_image=postgres_image,
build=build,
)
docker_blackbox_follow_seed_leader(
c,
source_host_port=source_host_port,
follower_host_port=follower_host_port,
app_image=app_image,
runner_image=runner_image,
postgres_image=postgres_image,
)
_print_header("Seeded leader/follower manual setup ready")
print(f"Leader URL: http://127.0.0.1:{source_host_port}")
print(f"Follower URL: http://127.0.0.1:{follower_host_port}")
print(f"Stop it with: inv docker-blackbox-follow-down")