Merge branch 'tur-95-backend-beamer-actioncable'

This commit is contained in:
Daniel Schädler 2026-04-23 17:17:16 +02:00
commit 154119ed94
12 changed files with 497 additions and 13 deletions

View File

@ -30,6 +30,7 @@ bundle exec rspec
Leader/follower tournament sync docs: Leader/follower tournament sync docs:
- [doc/leader_follower.md](doc/leader_follower.md) - [doc/leader_follower.md](doc/leader_follower.md)
- [doc/beamer_live_updates.md](doc/beamer_live_updates.md)
## Docker ## Docker
[Registry](https://gitlab.com/turniere/turniere-backend/container_registry) [Registry](https://gitlab.com/turniere/turniere-backend/container_registry)

View File

@ -0,0 +1,20 @@
# frozen_string_literal: true
class TournamentBeamersChannel < ApplicationCable::Channel
class << self
def stream_name(tournament_id:, beamer_name:)
"tournament_#{tournament_id}:beamers:#{beamer_name}"
end
end
def subscribed
tournament = Tournament.find(params[:tournament_id])
beamer_name = params[:beamer_name].presence
beamer = tournament.beamers.find_by(name: beamer_name) || tournament.beamers.find_by(name: Beamer::DEFAULT_NAME)
return reject unless beamer
stream_from self.class.stream_name(tournament_id: tournament.id, beamer_name: beamer.name)
transmit BeamerBroadcastPayload.build(beamer:, event: 'snapshot')
end
end

View File

@ -13,4 +13,37 @@ class Beamer < ApplicationRecord
validates :display_state, presence: true, inclusion: { in: DISPLAY_STATES } validates :display_state, presence: true, inclusion: { in: DISPLAY_STATES }
delegate :owner, to: :tournament delegate :owner, to: :tournament
before_update :store_previous_name_for_broadcast, if: :will_save_change_to_name?
before_destroy :store_snapshot_for_broadcast
after_commit :broadcast_state_change, on: %i[create update destroy]
private
def store_previous_name_for_broadcast
@broadcast_previous_name = name_in_database
end
def store_snapshot_for_broadcast
@broadcast_snapshot = BeamerBroadcastPayload.snapshot(self)
end
def broadcast_state_change
snapshot = @broadcast_snapshot || BeamerBroadcastPayload.snapshot(self)
BeamerBroadcast.call(
tournament_id: snapshot.fetch(:tournament_id),
beamer_names: [@broadcast_previous_name, snapshot.fetch(:name)].compact.uniq,
payload: BeamerBroadcastPayload.build(snapshot: snapshot, event: broadcast_event)
)
ensure
@broadcast_previous_name = nil
@broadcast_snapshot = nil
end
def broadcast_event
return 'deleted' if destroyed?
previous_changes.key?('id') ? 'created' : 'updated'
end
end end

View File

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

View File

@ -0,0 +1,14 @@
# frozen_string_literal: true
class BeamerBroadcast
class << self
def call(tournament_id:, beamer_names:, payload:)
Array(beamer_names).compact.uniq.each do |beamer_name|
ActionCable.server.broadcast(
TournamentBeamersChannel.stream_name(tournament_id:, beamer_name:),
payload
)
end
end
end
end

View File

@ -0,0 +1,39 @@
# frozen_string_literal: true
class BeamerBroadcastPayload
class << self
def build(beamer: nil, snapshot: nil, event:)
data = snapshot || self.snapshot(beamer)
{
type: "beamer.#{event}",
event: event,
tournament_id: data.fetch(:tournament_id),
beamer: serialized_beamer(data)
}
end
def snapshot(beamer)
{
id: beamer.id,
tournament_id: beamer.tournament_id,
name: beamer.name,
display_state: beamer.display_state,
qr_code_url: beamer.qr_code_url,
logo_url: beamer.logo_url
}
end
private
def serialized_beamer(snapshot)
{
id: snapshot.fetch(:id),
name: snapshot.fetch(:name),
display_state: snapshot.fetch(:display_state),
qr_code_url: snapshot[:qr_code_url],
logo_url: snapshot[:logo_url]
}
end
end
end

View File

@ -0,0 +1,64 @@
# Beamer Live Updates
Beamer/projector live updates are backend-owned and use Rails ActionCable at `/cable`.
## Goal
Frontend fullscreen pages talk only to `turniere-backend`.
No `TURNIERE_MATCH_URL`.
No Socket.IO dependency on `turniere-match`.
## Transport
- websocket endpoint: `GET /cable`
- ActionCable channel: `TournamentBeamersChannel`
- subscription params:
- `tournament_id`
- `beamer_name`
If requested beamer does not exist, channel falls back to tournament default beamer when present.
## Payload contract
Initial subscribe snapshot:
```json
{
"type": "beamer.snapshot",
"event": "snapshot",
"tournament_id": 12,
"beamer": {
"id": 34,
"name": "default",
"display_state": "matches_all",
"qr_code_url": null,
"logo_url": null
}
}
```
Mutation events use same `beamer` shape with `type` / `event` values:
- `beamer.created`
- `beamer.updated`
- `beamer.deleted`
## Broadcast source
Broadcasts come from `Beamer` model commit callbacks.
That means normal HTTP writes and follower-sync imports use same broadcast path.
Covered mutations:
- create
- update
- delete
## Frontend expectation
Frontend should:
- fetch beamer config from backend REST API
- subscribe to `TournamentBeamersChannel`
- update fullscreen state from explicit `beamer` payload
- reconnect if beamer name changes or current beamer gets deleted

View File

@ -17,25 +17,26 @@ module TurniereE2E
end end
def subscribe_team_action_lists!(tournament_id:) def subscribe_team_action_lists!(tournament_id:)
connect!
identifier = { identifier = {
channel: 'TournamentTeamActionListsChannel', channel: 'TournamentTeamActionListsChannel',
tournament_id: tournament_id tournament_id: tournament_id
}.to_json }.to_json
driver.text( subscribe!(identifier:) do |message|
JSON.dump( team_action_lists_payload_message?(message)
command: 'subscribe',
identifier: identifier
)
)
handshake_message = wait_for! do |message|
message['type'] == 'confirm_subscription' ||
(message['identifier'] == identifier && message['message'].is_a?(Hash))
end end
end
@messages << handshake_message if team_action_lists_payload_message?(handshake_message) def subscribe_beamer!(tournament_id:, beamer_name:)
identifier = {
channel: 'TournamentBeamersChannel',
tournament_id: tournament_id,
beamer_name: beamer_name
}.to_json
subscribe!(identifier:) do |message|
beamer_payload_message?(message)
end
end end
def wait_for_payload!(timeout: 20) def wait_for_payload!(timeout: 20)
@ -45,6 +46,12 @@ module TurniereE2E
end.fetch('message') end.fetch('message')
end end
def wait_for_beamer_payload!(timeout: 20)
wait_for!(timeout: timeout) do |message|
beamer_payload_message?(message)
end.fetch('message')
end
def close def close
return if @closed return if @closed
@ -67,6 +74,24 @@ module TurniereE2E
attr_reader :base_url, :socket, :driver attr_reader :base_url, :socket, :driver
def subscribe!(identifier:)
connect!
driver.text(
JSON.dump(
command: 'subscribe',
identifier: identifier
)
)
handshake_message = wait_for! do |message|
message['type'] == 'confirm_subscription' ||
(message['identifier'] == identifier && yield(message))
end
@messages << handshake_message if yield(handshake_message)
end
def connect! def connect!
return unless @driver.nil? return unless @driver.nil?
@ -143,5 +168,10 @@ module TurniereE2E
payload = message['message'] payload = message['message']
payload.is_a?(Hash) && payload['type'] == 'team_action_lists.updated' payload.is_a?(Hash) && payload['type'] == 'team_action_lists.updated'
end end
def beamer_payload_message?(message)
payload = message['message']
payload.is_a?(Hash) && payload['type'].to_s.start_with?('beamer.')
end
end end
end end

View File

@ -0,0 +1,101 @@
# frozen_string_literal: true
require 'spec_helper'
require 'securerandom'
require_relative '../../../e2e/lib/api_client'
require_relative '../../../e2e/lib/action_cable_client'
RSpec.describe 'Tournament beamer websocket HTTP E2E' do
required_env_vars = %w[
TURNIERE_E2E_BASE_URL
TURNIERE_E2E_EMAIL
TURNIERE_E2E_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(:client) { login_client }
it 'broadcasts beamer snapshots and updates over websocket' do
tournament = create_tournament(name_prefix: 'Ws Beamer')
create_beamer = client.post("/tournaments/#{tournament.fetch(:id)}/beamers", body: {
name: 'projector-a',
display_state: 'matches_all'
})
expect(create_beamer[:status]).to eq(201)
beamer = create_beamer.fetch(:json)
cable_client = TurniereE2E::ActionCableClient.new(base_url:)
cable_client.subscribe_beamer!(tournament_id: tournament.fetch(:id), beamer_name: beamer.fetch(:name))
initial_payload = cable_client.wait_for_beamer_payload!
expect(initial_payload.fetch('type')).to eq('beamer.snapshot')
expect(initial_payload.dig('beamer', 'id')).to eq(beamer.fetch(:id))
expect(initial_payload.dig('beamer', 'display_state')).to eq('matches_all')
update = client.patch("/tournaments/#{tournament.fetch(:id)}/beamers/#{beamer.fetch(:id)}", body: {
display_state: 'during_game',
qr_code_url: 'https://example.com/qr',
logo_url: '/static/logo.png'
})
expect(update[:status]).to eq(200)
updated_payload = wait_for_beamer_payload!(
cable_client,
event_type: 'beamer.updated',
display_state: 'during_game'
)
expect(updated_payload.dig('beamer', 'qr_code_url')).to eq('https://example.com/qr')
expect(updated_payload.dig('beamer', 'logo_url')).to eq('/static/logo.png')
delete = client.delete("/tournaments/#{tournament.fetch(:id)}/beamers/#{beamer.fetch(:id)}")
expect(delete[:status]).to eq(204)
deleted_payload = wait_for_beamer_payload!(
cable_client,
event_type: 'beamer.deleted',
display_state: 'during_game'
)
expect(deleted_payload.dig('beamer', 'name')).to eq('projector-a')
ensure
cable_client&.close
end
def login_client
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(name_prefix:)
response = client.post('/tournaments', body: {
name: "#{name_prefix} #{SecureRandom.hex(3)}",
description: 'Websocket beamer tournament',
public: true,
teams: 4.times.map do |index|
{ name: "#{name_prefix.tr(' ', '')}-S#{index + 1}" }
end
})
expect(response[:status]).to eq(201)
response.fetch(:json)
end
def wait_for_beamer_payload!(cable_client, event_type:, display_state:, timeout: 20)
deadline = Time.now + timeout
loop do
payload = cable_client.wait_for_beamer_payload!(timeout: [deadline - Time.now, 0.1].max)
return payload if payload.fetch('type') == event_type &&
payload.dig('beamer', 'display_state') == display_state
raise "beamer websocket payload not received within #{timeout}s" if Time.now >= deadline
end
end
end

View File

@ -0,0 +1,131 @@
# frozen_string_literal: true
require 'spec_helper'
require 'securerandom'
require_relative '../../../e2e/lib/api_client'
require_relative '../../../e2e/lib/action_cable_client'
DEFAULT_BEAMER_NAME = 'default'
RSpec.describe 'Tournament follower sync beamer websocket 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) }
it 'broadcasts follower beamer websocket updates after source sync import' do
source = create_tournament(client: source_client, base_url: source_base_url, name_prefix: 'Ws Beamer Source')
follower = create_follower_tournament(name_prefix: 'Ws Beamer Follower')
configure_sync = source_client.patch("/tournaments/#{source.fetch(:id)}", body: {
sync_target_url: "#{follower_base_url}/tournaments/#{follower.fetch(:id)}/sync_state",
sync_auth_token: sync_token
})
expect(configure_sync[:status]).to eq(200)
follower_default_beamer = wait_for_default_beamer!(tournament_id: follower.fetch(:id))
cable_client = TurniereE2E::ActionCableClient.new(base_url: follower_base_url)
cable_client.subscribe_beamer!(tournament_id: follower.fetch(:id), beamer_name: follower_default_beamer.fetch(:name))
cable_client.wait_for_beamer_payload!
source_default_beamer = fetch_beamer(base_url: source_base_url, tournament_id: source.fetch(:id), beamer_name: DEFAULT_BEAMER_NAME)
update = source_client.patch("/tournaments/#{source.fetch(:id)}/beamers/#{source_default_beamer.fetch(:id)}", body: {
display_state: 'groups'
})
expect(update[:status]).to eq(200)
follower_payload = wait_for_beamer_payload!(
cable_client,
event_type: 'beamer.updated',
display_state: 'groups'
)
expect(follower_payload.dig('beamer', 'name')).to eq(DEFAULT_BEAMER_NAME)
ensure
cable_client&.close
end
def login_client(base_url:)
client = TurniereE2E::ApiClient.new(base_url:)
response = client.login!(email: owner_email, password: owner_password)
expect(response[:status]).to eq(200)
client
end
def create_tournament(client:, base_url:, name_prefix:)
response = client.post('/tournaments', body: {
name: "#{name_prefix} #{SecureRandom.hex(3)}",
description: 'Follower beamer websocket source tournament',
public: true,
teams: 4.times.map do |index|
{ name: "#{name_prefix.tr(' ', '')}-S#{index + 1}" }
end
})
expect(response[:status]).to eq(201)
fetch_tournament(base_url:, tournament_id: response.dig(:json, :id))
end
def create_follower_tournament(name_prefix:)
client = login_client(base_url: follower_base_url)
response = client.post('/tournaments', body: {
name: "#{name_prefix} #{SecureRandom.hex(3)}",
description: 'Follower beamer websocket target tournament',
public: true,
read_only_mode: true,
sync_auth_token: sync_token
})
expect(response[:status]).to eq(201)
fetch_tournament(base_url: follower_base_url, tournament_id: response.dig(:json, :id))
end
def fetch_tournament(base_url:, tournament_id:)
client = TurniereE2E::ApiClient.new(base_url:)
response = client.get("/tournaments/#{tournament_id}")
expect(response[:status]).to eq(200)
response.fetch(:json)
end
def fetch_beamer(base_url:, tournament_id:, beamer_name:)
client = TurniereE2E::ApiClient.new(base_url:)
response = client.get("/tournaments/#{tournament_id}/beamers/by_name", params: { name: beamer_name })
expect(response[:status]).to eq(200)
response.fetch(:json)
end
def wait_for_default_beamer!(tournament_id:, timeout: 20)
deadline = Time.now + timeout
loop do
beamer = fetch_beamer(base_url: follower_base_url, tournament_id:, beamer_name: DEFAULT_BEAMER_NAME)
return beamer if beamer.fetch(:name) == DEFAULT_BEAMER_NAME
raise "follower default beamer not present within #{timeout}s" if Time.now >= deadline
sleep 0.5
end
end
def wait_for_beamer_payload!(cable_client, event_type:, display_state:, timeout: 20)
deadline = Time.now + timeout
loop do
payload = cable_client.wait_for_beamer_payload!(timeout: [deadline - Time.now, 0.1].max)
return payload if payload.fetch('type') == event_type &&
payload.dig('beamer', 'display_state') == display_state
raise "follower beamer websocket payload not received within #{timeout}s" if Time.now >= deadline
end
end
end

View File

@ -42,4 +42,54 @@ RSpec.describe Beamer, type: :model do
expect(beamer.owner).to eq(beamer.tournament.owner) expect(beamer.owner).to eq(beamer.tournament.owner)
end end
end end
describe 'live broadcasts' do
it 'broadcasts create payload to current beamer stream' do
tournament = create(:tournament)
payload = nil
expect(BeamerBroadcast).to receive(:call) do |args|
payload = args.fetch(:payload)
expect(args.fetch(:tournament_id)).to eq(tournament.id)
expect(args.fetch(:beamer_names)).to eq(['projector'])
end
tournament.beamers.create!(name: 'projector', display_state: 'groups')
expect(payload[:type]).to eq('beamer.created')
expect(payload.dig(:beamer, :display_state)).to eq('groups')
end
it 'broadcasts update payload to previous and current beamer names when renamed' do
beamer = create(:beamer, name: 'old-name')
payload = nil
expect(BeamerBroadcast).to receive(:call) do |args|
payload = args.fetch(:payload)
expect(args.fetch(:beamer_names)).to contain_exactly('old-name', 'new-name')
end
beamer.update!(name: 'new-name', display_state: 'during_game')
expect(payload[:type]).to eq('beamer.updated')
expect(payload.dig(:beamer, :name)).to eq('new-name')
expect(payload.dig(:beamer, :display_state)).to eq('during_game')
end
it 'broadcasts delete payload with last persisted snapshot' do
beamer = create(:beamer, name: 'projector', display_state: 'between_games')
payload = nil
expect(BeamerBroadcast).to receive(:call) do |args|
payload = args.fetch(:payload)
expect(args.fetch(:beamer_names)).to eq(['projector'])
end
beamer.destroy!
expect(payload[:type]).to eq('beamer.deleted')
expect(payload.dig(:beamer, :name)).to eq('projector')
expect(payload.dig(:beamer, :display_state)).to eq('between_games')
end
end
end end

View File

@ -1725,6 +1725,7 @@ def blackbox_follow_service(
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_websocket_spec.rb", "spec/e2e/http/tournament_follow_sync_websocket_spec.rb",
"spec/e2e/http/tournament_follow_sync_beamers_websocket_spec.rb",
], ],
extra_env={ extra_env={
"TURNIERE_SOURCE_E2E_BASE_URL": source_base_url, "TURNIERE_SOURCE_E2E_BASE_URL": source_base_url,