From 366ee038f24bd2a5f879eee81621af7679d4df81 Mon Sep 17 00:00:00 2001 From: Malaber Date: Sun, 26 Apr 2026 18:48:50 +0200 Subject: [PATCH] test: cover beamer e2e controls --- spec/e2e/http/tournament_beamers_spec.rb | 154 ++++++++++++++ .../tournament_follow_sync_beamers_spec.rb | 198 ++++++++++++++++++ tasks.py | 1 + 3 files changed, 353 insertions(+) create mode 100644 spec/e2e/http/tournament_beamers_spec.rb create mode 100644 spec/e2e/http/tournament_follow_sync_beamers_spec.rb diff --git a/spec/e2e/http/tournament_beamers_spec.rb b/spec/e2e/http/tournament_beamers_spec.rb new file mode 100644 index 0000000..268dded --- /dev/null +++ b/spec/e2e/http/tournament_beamers_spec.rb @@ -0,0 +1,154 @@ +# frozen_string_literal: true + +require 'spec_helper' +require 'securerandom' +require_relative '../../../e2e/lib/api_client' + +RSpec.describe 'Tournament beamer HTTP E2E' do + required_env_vars = %w[ + TURNIERE_E2E_BASE_URL + TURNIERE_E2E_EMAIL + TURNIERE_E2E_PASSWORD + TURNIERE_E2E_ALT_EMAIL + TURNIERE_E2E_ALT_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(:other_email) { ENV.fetch('TURNIERE_E2E_ALT_EMAIL') } + let(:other_password) { ENV.fetch('TURNIERE_E2E_ALT_PASSWORD') } + let(:owner_client) { login_client(email: owner_email, password: owner_password) } + let(:other_client) { login_client(email: other_email, password: other_password) } + let(:anonymous_client) { TurniereE2E::ApiClient.new(base_url:) } + + it 'exercises beamer control endpoints over HTTP' do + tournament = create_tournament(client: owner_client, name_prefix: 'Beamer Control') + + default_beamer = anonymous_client.get( + beamer_by_name_path(tournament.fetch(:id)), + params: { name: 'missing' } + ) + default_show = anonymous_client.get(beamer_path(tournament.fetch(:id), default_beamer.dig(:json, :id))) + + expect(default_beamer[:status]).to eq(200) + expect(default_beamer.dig(:json, :name)).to eq('default') + expect(default_beamer.dig(:json, :display_state)).to eq('matches_all') + expect(default_show[:status]).to eq(200) + + create_response = owner_client.post("/tournaments/#{tournament.fetch(:id)}/beamers", body: { + name: 'hall-a', + display_state: 'groups', + qr_code_url: 'https://example.com/hall-a', + logo_url: '/logos/hall-a.png' + }) + expect(create_response[:status]).to eq(201) + beamer = create_response.fetch(:json) + expect(beamer.fetch(:name)).to eq('hall-a') + expect(beamer.fetch(:display_state)).to eq('groups') + expect(beamer.fetch(:qr_code_url)).to eq('https://example.com/hall-a') + + index_response = anonymous_client.get("/tournaments/#{tournament.fetch(:id)}/beamers") + show_response = anonymous_client.get("/tournaments/#{tournament.fetch(:id)}/beamers/#{beamer.fetch(:id)}") + by_name_response = anonymous_client.get(beamer_by_name_path(tournament.fetch(:id)), params: { name: 'hall-a' }) + + expect(index_response[:status]).to eq(200) + expect(index_response.fetch(:json).map { |item| item.fetch(:name) }).to contain_exactly('default', 'hall-a') + expect(show_response[:status]).to eq(200) + expect(show_response.dig(:json, :id)).to eq(beamer.fetch(:id)) + expect(by_name_response[:status]).to eq(200) + expect(by_name_response.dig(:json, :id)).to eq(beamer.fetch(:id)) + + update_response = owner_client.patch("/tournaments/#{tournament.fetch(:id)}/beamers/#{beamer.fetch(:id)}", body: { + display_state: 'during_game', + qr_code_url: 'https://example.com/live', + logo_url: '/logos/live.png' + }) + expect(update_response[:status]).to eq(200) + expect(update_response.dig(:json, :display_state)).to eq('during_game') + expect(update_response.dig(:json, :qr_code_url)).to eq('https://example.com/live') + expect(update_response.dig(:json, :logo_url)).to eq('/logos/live.png') + + invalid_update = owner_client.patch("/tournaments/#{tournament.fetch(:id)}/beamers/#{beamer.fetch(:id)}", body: { + display_state: 'bogus' + }) + duplicate_create = owner_client.post("/tournaments/#{tournament.fetch(:id)}/beamers", body: { + name: 'hall-a', + display_state: 'matches_all' + }) + forbidden_update = other_client.patch("/tournaments/#{tournament.fetch(:id)}/beamers/#{beamer.fetch(:id)}", body: { + display_state: 'groups' + }) + unauthenticated_create = anonymous_client.post( + "/tournaments/#{tournament.fetch(:id)}/beamers", + body: { + name: 'anon', + display_state: 'groups' + } + ) + + expect(invalid_update[:status]).to eq(422) + expect(duplicate_create[:status]).to eq(422) + expect(forbidden_update[:status]).to eq(403) + expect(unauthenticated_create[:status]).to eq(401) + + read_only_tournament = create_tournament( + client: owner_client, + name_prefix: 'Beamer Read Only', + read_only_mode: true + ) + read_only_beamer = anonymous_client.get( + beamer_by_name_path(read_only_tournament.fetch(:id)), + params: { name: 'default' } + ) + locked_update = owner_client.patch( + beamer_path(read_only_tournament.fetch(:id), read_only_beamer.dig(:json, :id)), + body: { display_state: 'groups' } + ) + expect(locked_update[:status]).to eq(423) + + delete_response = owner_client.delete("/tournaments/#{tournament.fetch(:id)}/beamers/#{beamer.fetch(:id)}") + deleted_show = anonymous_client.get("/tournaments/#{tournament.fetch(:id)}/beamers/#{beamer.fetch(:id)}") + deleted_by_name = anonymous_client.get(beamer_by_name_path(tournament.fetch(:id)), params: { name: 'hall-a' }) + + expect(delete_response[:status]).to eq(204) + expect(deleted_show[:status]).to eq(404) + expect(deleted_by_name[:status]).to eq(200) + expect(deleted_by_name.dig(:json, :name)).to eq('default') + end + + def login_client(email:, password:) + api_client = TurniereE2E::ApiClient.new(base_url:) + response = api_client.login!(email:, password:) + + expect(response[:status]).to eq(200) + api_client + end + + def create_tournament(client:, name_prefix:, read_only_mode: false) + response = client.post('/tournaments', body: { + name: "#{name_prefix} #{SecureRandom.hex(3)}", + description: 'HTTP beamer control tournament', + public: true, + read_only_mode:, + teams: Array.new(4) do |index| + { name: "#{name_prefix.tr(' ', '')}-S#{index + 1}" } + end + }) + expect(response[:status]).to eq(201) + response.fetch(:json) + end + + def beamer_path(tournament_id, beamer_id) + "/tournaments/#{tournament_id}/beamers/#{beamer_id}" + end + + def beamer_by_name_path(tournament_id) + "/tournaments/#{tournament_id}/beamers/by_name" + end +end diff --git a/spec/e2e/http/tournament_follow_sync_beamers_spec.rb b/spec/e2e/http/tournament_follow_sync_beamers_spec.rb new file mode 100644 index 0000000..4095fda --- /dev/null +++ b/spec/e2e/http/tournament_follow_sync_beamers_spec.rb @@ -0,0 +1,198 @@ +# frozen_string_literal: true + +require 'spec_helper' +require 'securerandom' +require_relative '../../../e2e/lib/api_client' + +RSpec.describe 'Tournament follower sync beamer 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) } + let(:follower_client) { login_client(base_url: follower_base_url) } + let(:follower_read_client) { TurniereE2E::ApiClient.new(base_url: follower_base_url) } + + it 'mirrors leader beamer control changes into follower HTTP reads' do + source = create_tournament(client: source_client, name_prefix: 'Beamer Source') + follower = create_tournament(client: follower_client, name_prefix: 'Beamer Follower', read_only_mode: true) + + configure_sync = source_client.patch("/tournaments/#{source.fetch(:id)}", body: { + sync_target_url: sync_state_url(follower.fetch(:id)), + sync_auth_token: sync_token + }) + expect(configure_sync[:status]).to eq(200) + sync_now_if_requested!(source.fetch(:id)) + + source_default = fetch_beamer(client: source_client, tournament_id: source.fetch(:id), beamer_name: 'default') + follower_default = wait_for_follower_beamer!( + tournament_id: follower.fetch(:id), + beamer_name: 'default', + display_state: source_default.fetch(:display_state) + ) + expect(follower_default.fetch(:name)).to eq('default') + + leader_default_update = source_client.patch( + beamer_path(source.fetch(:id), source_default.fetch(:id)), + body: { + display_state: 'between_games', + qr_code_url: 'https://example.com/leader-default', + logo_url: '/logos/default.png' + } + ) + expect(leader_default_update[:status]).to eq(200) + sync_now_if_requested!(source.fetch(:id)) + + mirrored_default = wait_for_follower_beamer!( + tournament_id: follower.fetch(:id), + beamer_name: 'default', + display_state: 'between_games', + qr_code_url: 'https://example.com/leader-default', + logo_url: '/logos/default.png' + ) + expect(mirrored_default.fetch(:name)).to eq('default') + + leader_create = source_client.post("/tournaments/#{source.fetch(:id)}/beamers", body: { + name: 'stage-left', + display_state: 'groups', + qr_code_url: 'https://example.com/stage-left', + logo_url: '/logos/stage-left.png' + }) + expect(leader_create[:status]).to eq(201) + sync_now_if_requested!(source.fetch(:id)) + + mirrored_created = wait_for_follower_beamer!( + tournament_id: follower.fetch(:id), + beamer_name: 'stage-left', + display_state: 'groups', + qr_code_url: 'https://example.com/stage-left', + logo_url: '/logos/stage-left.png' + ) + expect(mirrored_created.fetch(:name)).to eq('stage-left') + + leader_update = source_client.patch( + beamer_path(source.fetch(:id), leader_create.dig(:json, :id)), + body: { + display_state: 'during_game', + qr_code_url: 'https://example.com/stage-left-live' + } + ) + expect(leader_update[:status]).to eq(200) + sync_now_if_requested!(source.fetch(:id)) + + mirrored_updated = wait_for_follower_beamer!( + tournament_id: follower.fetch(:id), + beamer_name: 'stage-left', + display_state: 'during_game', + qr_code_url: 'https://example.com/stage-left-live' + ) + expect(mirrored_updated.fetch(:logo_url)).to eq('/logos/stage-left.png') + + locked_follower_update = follower_client.patch( + beamer_path(follower.fetch(:id), mirrored_updated.fetch(:id)), + body: { display_state: 'matches_finished' } + ) + expect(locked_follower_update[:status]).to eq(423) + + leader_delete = source_client.delete("/tournaments/#{source.fetch(:id)}/beamers/#{leader_create.dig(:json, :id)}") + expect(leader_delete[:status]).to eq(204) + sync_now_if_requested!(source.fetch(:id)) + + wait_for_follower_beamer_removed!( + tournament_id: follower.fetch(:id), + beamer_name: 'stage-left' + ) + end + + def login_client(base_url:) + 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(client:, name_prefix:, read_only_mode: false) + response = client.post('/tournaments', body: tournament_payload(name_prefix:, read_only_mode:)) + expect(response[:status]).to eq(201) + response.fetch(:json) + end + + def fetch_beamer(client:, tournament_id:, beamer_name:) + response = client.get(beamer_by_name_path(tournament_id), params: { name: beamer_name }) + expect(response[:status]).to eq(200) + response.fetch(:json) + end + + def sync_now_if_requested!(source_tournament_id) + return unless ENV.fetch('TURNIERE_E2E_SYNC_MODE', nil) == 'manual' + + response = source_client.post("/tournaments/#{source_tournament_id}/test_sync") + expect(response[:status]).to eq(200) + end + + def wait_for_follower_beamer!(tournament_id:, beamer_name:, timeout: 20, **expected_attrs) + deadline = Time.now.utc + timeout + loop do + response = follower_read_client.get(beamer_by_name_path(tournament_id), params: { name: beamer_name }) + if response[:status] == 200 && response.dig(:json, :name) == beamer_name + beamer = response.fetch(:json) + return beamer if expected_attrs.all? { |key, value| beamer.fetch(key) == value } + end + + raise "follower beamer #{beamer_name} not synced within #{timeout}s" if Time.now.utc >= deadline + + sleep 0.5 + end + end + + def wait_for_follower_beamer_removed!(tournament_id:, beamer_name:, timeout: 20) + deadline = Time.now.utc + timeout + loop do + response = follower_read_client.get(beamer_by_name_path(tournament_id), params: { name: beamer_name }) + return if response[:status] == 200 && response.dig(:json, :name) != beamer_name + + raise "follower beamer #{beamer_name} not removed within #{timeout}s" if Time.now.utc >= deadline + + sleep 0.5 + end + end + + def tournament_payload(name_prefix:, read_only_mode:) + { + name: "#{name_prefix} #{SecureRandom.hex(3)}", + description: 'Follower beamer HTTP tournament', + public: true, + read_only_mode:, + sync_auth_token: read_only_mode ? sync_token : nil, + teams: Array.new(4) do |index| + { name: "#{name_prefix.tr(' ', '')}-S#{index + 1}" } + end + }.compact + end + + def sync_state_url(tournament_id) + "#{follower_base_url}/tournaments/#{tournament_id}/sync_state" + end + + def beamer_path(tournament_id, beamer_id) + "/tournaments/#{tournament_id}/beamers/#{beamer_id}" + end + + def beamer_by_name_path(tournament_id) + "/tournaments/#{tournament_id}/beamers/by_name" + end +end diff --git a/tasks.py b/tasks.py index a1b8ac3..e2de58e 100644 --- a/tasks.py +++ b/tasks.py @@ -1764,6 +1764,7 @@ def blackbox_follow_service( alt_username, spec_path=[ "spec/e2e/http/tournament_follow_sync_spec.rb", + "spec/e2e/http/tournament_follow_sync_beamers_spec.rb", "spec/e2e/http/tournament_follow_sync_websocket_spec.rb", "spec/e2e/http/tournament_follow_sync_live_websocket_spec.rb", "spec/e2e/http/tournament_timer_follow_sync_websocket_spec.rb",