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:
commit
d3176e6968
|
|
@ -26,9 +26,6 @@
|
||||||
# IDEA
|
# IDEA
|
||||||
.idea/
|
.idea/
|
||||||
|
|
||||||
# database schema
|
|
||||||
db/schema.rb
|
|
||||||
|
|
||||||
#Ignore Coverage
|
#Ignore Coverage
|
||||||
coverage/**
|
coverage/**
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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!
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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 => {
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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"
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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 })
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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
193
tasks.py
|
|
@ -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")
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue