diff --git a/README.md b/README.md index 90bf18f..400b4c0 100644 --- a/README.md +++ b/README.md @@ -30,6 +30,7 @@ bundle exec rspec Leader/follower tournament sync docs: - [doc/leader_follower.md](doc/leader_follower.md) +- [doc/beamer_live_updates.md](doc/beamer_live_updates.md) ## Docker [Registry](https://gitlab.com/turniere/turniere-backend/container_registry) diff --git a/app/channels/tournament_beamers_channel.rb b/app/channels/tournament_beamers_channel.rb new file mode 100644 index 0000000..cbcbf7e --- /dev/null +++ b/app/channels/tournament_beamers_channel.rb @@ -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 diff --git a/app/models/beamer.rb b/app/models/beamer.rb index 180f915..47f44e0 100644 --- a/app/models/beamer.rb +++ b/app/models/beamer.rb @@ -13,4 +13,37 @@ class Beamer < ApplicationRecord validates :display_state, presence: true, inclusion: { in: DISPLAY_STATES } 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 diff --git a/app/serializers/beamer_serializer.rb b/app/serializers/beamer_serializer.rb index ca15284..d6863f1 100644 --- a/app/serializers/beamer_serializer.rb +++ b/app/serializers/beamer_serializer.rb @@ -1,5 +1,5 @@ # frozen_string_literal: true class BeamerSerializer < ApplicationSerializer - attributes :name, :display_state, :qr_code_url, :logo_url + attributes :id, :name, :display_state, :qr_code_url, :logo_url end diff --git a/app/services/beamer_broadcast.rb b/app/services/beamer_broadcast.rb new file mode 100644 index 0000000..4fba0a2 --- /dev/null +++ b/app/services/beamer_broadcast.rb @@ -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 diff --git a/app/services/beamer_broadcast_payload.rb b/app/services/beamer_broadcast_payload.rb new file mode 100644 index 0000000..cc47b80 --- /dev/null +++ b/app/services/beamer_broadcast_payload.rb @@ -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 diff --git a/doc/beamer_live_updates.md b/doc/beamer_live_updates.md new file mode 100644 index 0000000..155349e --- /dev/null +++ b/doc/beamer_live_updates.md @@ -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 diff --git a/e2e/lib/action_cable_client.rb b/e2e/lib/action_cable_client.rb index b07a265..ff0c4dd 100644 --- a/e2e/lib/action_cable_client.rb +++ b/e2e/lib/action_cable_client.rb @@ -17,25 +17,26 @@ module TurniereE2E end def subscribe_team_action_lists!(tournament_id:) - connect! identifier = { channel: 'TournamentTeamActionListsChannel', tournament_id: tournament_id }.to_json - driver.text( - JSON.dump( - command: 'subscribe', - identifier: identifier - ) - ) - - handshake_message = wait_for! do |message| - message['type'] == 'confirm_subscription' || - (message['identifier'] == identifier && message['message'].is_a?(Hash)) + subscribe!(identifier:) do |message| + team_action_lists_payload_message?(message) 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 def wait_for_payload!(timeout: 20) @@ -45,6 +46,12 @@ module TurniereE2E end.fetch('message') end + def wait_for_beamer_payload!(timeout: 20) + wait_for!(timeout: timeout) do |message| + beamer_payload_message?(message) + end.fetch('message') + end + def close return if @closed @@ -67,6 +74,24 @@ module TurniereE2E 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! return unless @driver.nil? @@ -143,5 +168,10 @@ module TurniereE2E payload = message['message'] payload.is_a?(Hash) && payload['type'] == 'team_action_lists.updated' end + + def beamer_payload_message?(message) + payload = message['message'] + payload.is_a?(Hash) && payload['type'].to_s.start_with?('beamer.') + end end end diff --git a/spec/e2e/http/tournament_beamers_websocket_spec.rb b/spec/e2e/http/tournament_beamers_websocket_spec.rb new file mode 100644 index 0000000..9c68aa5 --- /dev/null +++ b/spec/e2e/http/tournament_beamers_websocket_spec.rb @@ -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 diff --git a/spec/e2e/http/tournament_follow_sync_beamers_websocket_spec.rb b/spec/e2e/http/tournament_follow_sync_beamers_websocket_spec.rb new file mode 100644 index 0000000..05e7df4 --- /dev/null +++ b/spec/e2e/http/tournament_follow_sync_beamers_websocket_spec.rb @@ -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 diff --git a/spec/models/beamer_spec.rb b/spec/models/beamer_spec.rb index 34d77d1..ba9681b 100644 --- a/spec/models/beamer_spec.rb +++ b/spec/models/beamer_spec.rb @@ -42,4 +42,54 @@ RSpec.describe Beamer, type: :model do expect(beamer.owner).to eq(beamer.tournament.owner) 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