feat: add backend beamer live updates
This commit is contained in:
parent
e41ef870f9
commit
7151976d26
|
|
@ -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)
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
@ -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
|
||||||
|
|
@ -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
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,98 @@
|
||||||
|
# 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
|
||||||
|
})
|
||||||
|
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
|
||||||
|
|
@ -0,0 +1,126 @@
|
||||||
|
# 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 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: Beamer::DEFAULT_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(Beamer::DEFAULT_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
|
||||||
|
})
|
||||||
|
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: Beamer::DEFAULT_NAME)
|
||||||
|
return beamer if beamer.fetch(:name) == Beamer::DEFAULT_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
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue