From afff8139948f8163c467bdcfe9567dba7d69abf4 Mon Sep 17 00:00:00 2001 From: Malaber Date: Thu, 23 Apr 2026 23:29:03 +0200 Subject: [PATCH] feat: add timer modes and websocket updates --- app/channels/tournament_timers_channel.rb | 9 ++ app/controllers/tournaments_controller.rb | 51 +++++++-- app/models/tournament.rb | 10 ++ app/serializers/tournament_serializer.rb | 2 + app/services/tournament_snapshot_builder.rb | 1 + app/services/tournament_snapshot_importer.rb | 1 + app/services/tournament_sync_schema.rb | 1 + app/services/tournament_timer_broadcast.rb | 15 +++ .../tournament_timer_broadcast_payload.rb | 14 +++ ...423133000_add_timer_mode_to_tournaments.rb | 5 + e2e/lib/action_cable_client.rb | 55 +++++---- .../tournaments_controller_spec.rb | 38 ++++++- spec/e2e/http/api_surface_spec.rb | 16 +++ ...nament_timer_follow_sync_websocket_spec.rb | 107 ++++++++++++++++++ .../http/tournament_timer_websocket_spec.rb | 86 ++++++++++++++ .../tournament_snapshot_importer_spec.rb | 2 + tasks.py | 3 +- 17 files changed, 384 insertions(+), 32 deletions(-) create mode 100644 app/channels/tournament_timers_channel.rb create mode 100644 app/services/tournament_timer_broadcast.rb create mode 100644 app/services/tournament_timer_broadcast_payload.rb create mode 100644 db/migrate/20260423133000_add_timer_mode_to_tournaments.rb create mode 100644 spec/e2e/http/tournament_timer_follow_sync_websocket_spec.rb create mode 100644 spec/e2e/http/tournament_timer_websocket_spec.rb diff --git a/app/channels/tournament_timers_channel.rb b/app/channels/tournament_timers_channel.rb new file mode 100644 index 0000000..b630d13 --- /dev/null +++ b/app/channels/tournament_timers_channel.rb @@ -0,0 +1,9 @@ +# frozen_string_literal: true + +class TournamentTimersChannel < ApplicationCable::Channel + def subscribed + tournament = Tournament.find(params[:tournament_id]) + stream_for tournament + transmit TournamentTimerBroadcastPayload.build(tournament) + end +end diff --git a/app/controllers/tournaments_controller.rb b/app/controllers/tournaments_controller.rb index c64e8a4..aaabd93 100644 --- a/app/controllers/tournaments_controller.rb +++ b/app/controllers/tournaments_controller.rb @@ -131,7 +131,7 @@ class TournamentsController < ApplicationController # GET /tournaments/:id/timer_end def timer_end - render json: { timer_end: serialize_timer_end(@tournament.timer_end) } + render json: serialize_timer_state(@tournament) end # PATCH /tournaments/:id/set_timer_end @@ -172,11 +172,17 @@ class TournamentsController < ApplicationController private def timer_end_params - { timer_end: params[:timer_end] } + { + timer_end: params[:timer_end], + timer_mode: params[:timer_mode] + } end - def serialize_timer_end(timer_end) - timer_end&.iso8601 + def serialize_timer_state(tournament) + { + timer_end: tournament.timer_end&.iso8601, + timer_mode: tournament.timer_mode + } end def organize_teams_in_groups(teams) @@ -329,12 +335,33 @@ class TournamentsController < ApplicationController def validate_set_timer_end_params timer_end = params[:timer_end] timer_end_seconds = params[:timer_end_seconds] + timer_mode = params[:timer_mode] + clear_timer = ActiveModel::Type::Boolean.new.cast(params[:clear_timer]) + + if clear_timer + if timer_end.present? || timer_end_seconds.present? || timer_mode.present? + return render json: { error: 'clear_timer cannot be combined with timer values or timer_mode' }, + status: :unprocessable_entity + end + + params[:timer_end] = nil + params[:timer_mode] = nil + return + end + + if timer_mode.present? && !%w[countdown countup].include?(timer_mode) + return render json: { error: 'Invalid timer_mode' }, status: :unprocessable_entity + end # throw error if both timer_end and timer_end_seconds are present if timer_end.present? && timer_end_seconds.present? return render json: { error: 'Only one of timer_end or timer_end_seconds is allowed' }, status: :unprocessable_entity end + if timer_mode == 'countup' && timer_end_seconds.present? + return render json: { error: 'timer_end_seconds is only allowed for countdown mode' }, status: :unprocessable_entity + end + if timer_end_seconds.present? begin timer_end_seconds = Integer(timer_end_seconds) @@ -346,21 +373,29 @@ class TournamentsController < ApplicationController parsed_time = Time.zone.now + timer_end_seconds params[:timer_end] = parsed_time + params[:timer_mode] = 'countdown' elsif timer_end.present? begin parsed_time = Time.zone.parse(timer_end) if parsed_time.nil? - return render json: { error: 'Invalid datetime format' }, status: :unprocessable_entity - elsif !parsed_time.future? + return render json: { error: 'Invalid timer_end format' }, status: :unprocessable_entity + elsif timer_mode != 'countup' && !parsed_time.future? return render json: { error: 'Timer end must be in the future' }, status: :unprocessable_entity end params[:timer_end] = parsed_time + params[:timer_mode] = timer_mode.presence || 'countdown' rescue ArgumentError - return render json: { error: 'Invalid datetime format' }, status: :unprocessable_entity + return render json: { error: 'Invalid timer_end format' }, status: :unprocessable_entity end + elsif timer_mode == 'countup' + params[:timer_end] = Time.zone.now + elsif timer_mode.present? + return render json: { error: 'timer_mode requires timer_end, timer_end_seconds, or clear_timer' }, + status: :unprocessable_entity else - return render json: { error: 'Timer end is required' }, status: :unprocessable_entity + return render json: { error: 'timer_end, timer_end_seconds, or clear_timer is required' }, + status: :unprocessable_entity end end end diff --git a/app/models/tournament.rb b/app/models/tournament.rb index 729fdab..653f738 100644 --- a/app/models/tournament.rb +++ b/app/models/tournament.rb @@ -11,6 +11,7 @@ class Tournament < ApplicationRecord validates :name, presence: true validates :code, presence: true, uniqueness: true + validates :timer_mode, inclusion: { in: %w[countdown countup] }, allow_nil: true validates :sync_target_url, format: URI::DEFAULT_PARSER.make_regexp(%w[http https]), allow_blank: true validate :sync_configuration_is_complete @@ -21,6 +22,7 @@ class Tournament < ApplicationRecord after_initialize :generate_code after_create_commit :ensure_default_beamer! before_validation :clear_follower_sync_token_when_disabling_read_only_mode + after_commit :broadcast_timer_state_change, if: :saved_change_to_timer_state? def matches [stages.map(&:matches), stages.map { |s| s.groups.map(&:matches) }].flatten @@ -92,4 +94,12 @@ class Tournament < ApplicationRecord self.sync_auth_token = nil end + + def saved_change_to_timer_state? + saved_change_to_timer_end? || saved_change_to_timer_mode? + end + + def broadcast_timer_state_change + TournamentTimerBroadcast.call(id) + end end diff --git a/app/serializers/tournament_serializer.rb b/app/serializers/tournament_serializer.rb index 9389365..abed8eb 100644 --- a/app/serializers/tournament_serializer.rb +++ b/app/serializers/tournament_serializer.rb @@ -15,6 +15,8 @@ class TournamentSerializer < SimpleTournamentSerializer object.timer_end&.iso8601 end + attribute :timer_mode + attribute :sync_last_pushed_at, if: :sync_metadata_visible? do object.sync_last_pushed_at&.iso8601 end diff --git a/app/services/tournament_snapshot_builder.rb b/app/services/tournament_snapshot_builder.rb index 48b61d4..03a5226 100644 --- a/app/services/tournament_snapshot_builder.rb +++ b/app/services/tournament_snapshot_builder.rb @@ -23,6 +23,7 @@ class TournamentSnapshotBuilder def build payload = tournament.attributes.symbolize_keys.slice(*ROOT_ATTRIBUTES) payload[:timer_end] = tournament.timer_end&.iso8601 + payload[:timer_mode] = tournament.timer_mode payload[:sync_generated_at] = Time.current.iso8601(6) payload[:teams] = teams_payload payload[:beamers] = beamers_payload diff --git a/app/services/tournament_snapshot_importer.rb b/app/services/tournament_snapshot_importer.rb index 0a1657f..4841792 100644 --- a/app/services/tournament_snapshot_importer.rb +++ b/app/services/tournament_snapshot_importer.rb @@ -68,6 +68,7 @@ class TournamentSnapshotImporter root_attrs = snapshot.slice(*ROOT_ATTRIBUTES) root_attrs[:code] = tournament.code if code_taken_by_another_tournament?(root_attrs[:code]) root_attrs[:timer_end] = parse_time(snapshot[:timer_end]) + root_attrs[:timer_mode] = snapshot[:timer_mode] root_attrs[:sync_source_tournament_id] = snapshot[:id] root_attrs[:sync_last_imported_snapshot_at] = snapshot_generated_at tournament.update!(root_attrs) diff --git a/app/services/tournament_sync_schema.rb b/app/services/tournament_sync_schema.rb index d31f49f..bf03726 100644 --- a/app/services/tournament_sync_schema.rb +++ b/app/services/tournament_sync_schema.rb @@ -13,6 +13,7 @@ class TournamentSyncSchema instant_finalists_amount intermediate_round_participants_amount timer_end + timer_mode ].freeze, ignored: %w[ user_id diff --git a/app/services/tournament_timer_broadcast.rb b/app/services/tournament_timer_broadcast.rb new file mode 100644 index 0000000..9d8e198 --- /dev/null +++ b/app/services/tournament_timer_broadcast.rb @@ -0,0 +1,15 @@ +# frozen_string_literal: true + +class TournamentTimerBroadcast + class << self + def call(tournament_id) + tournament = Tournament.find_by(id: tournament_id) + return if tournament.nil? + + TournamentTimersChannel.broadcast_to( + tournament, + TournamentTimerBroadcastPayload.build(tournament) + ) + end + end +end diff --git a/app/services/tournament_timer_broadcast_payload.rb b/app/services/tournament_timer_broadcast_payload.rb new file mode 100644 index 0000000..5640165 --- /dev/null +++ b/app/services/tournament_timer_broadcast_payload.rb @@ -0,0 +1,14 @@ +# frozen_string_literal: true + +class TournamentTimerBroadcastPayload + class << self + def build(tournament) + { + type: 'timer.updated', + tournament_id: tournament.id, + timer_end: tournament.timer_end&.iso8601, + timer_mode: tournament.timer_mode + } + end + end +end diff --git a/db/migrate/20260423133000_add_timer_mode_to_tournaments.rb b/db/migrate/20260423133000_add_timer_mode_to_tournaments.rb new file mode 100644 index 0000000..07f5ef2 --- /dev/null +++ b/db/migrate/20260423133000_add_timer_mode_to_tournaments.rb @@ -0,0 +1,5 @@ +class AddTimerModeToTournaments < ActiveRecord::Migration[7.0] + def change + add_column :tournaments, :timer_mode, :string + end +end diff --git a/e2e/lib/action_cable_client.rb b/e2e/lib/action_cable_client.rb index b07a265..a412813 100644 --- a/e2e/lib/action_cable_client.rb +++ b/e2e/lib/action_cable_client.rb @@ -17,25 +17,11 @@ module TurniereE2E end def subscribe_team_action_lists!(tournament_id:) - connect! - identifier = { - channel: 'TournamentTeamActionListsChannel', - tournament_id: tournament_id - }.to_json + subscribe!(channel: 'TournamentTeamActionListsChannel', tournament_id: tournament_id) + end - 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)) - end - - @messages << handshake_message if team_action_lists_payload_message?(handshake_message) + def subscribe_timer!(tournament_id:) + subscribe!(channel: 'TournamentTimersChannel', tournament_id: tournament_id) end def wait_for_payload!(timeout: 20) @@ -45,6 +31,13 @@ module TurniereE2E end.fetch('message') end + def wait_for_timer_payload!(timeout: 20) + wait_for!(timeout: timeout) do |message| + payload = message['message'] + payload.is_a?(Hash) && payload['type'] == 'timer.updated' + end.fetch('message') + end + def close return if @closed @@ -78,6 +71,28 @@ module TurniereE2E pump_until_open! end + def subscribe!(channel:, tournament_id:) + connect! + identifier = { + channel: channel, + 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)) + end + + @messages << handshake_message if payload_message?(handshake_message) + end + def build_socket uri = URI.parse(websocket_url) tcp_socket = TCPSocket.new(uri.host, uri.port) @@ -139,9 +154,9 @@ module TurniereE2E nil end - def team_action_lists_payload_message?(message) + def payload_message?(message) payload = message['message'] - payload.is_a?(Hash) && payload['type'] == 'team_action_lists.updated' + payload.is_a?(Hash) && %w[team_action_lists.updated timer.updated].include?(payload['type']) end end end diff --git a/spec/controllers/tournaments_controller_spec.rb b/spec/controllers/tournaments_controller_spec.rb index 6939459..99fb5b7 100644 --- a/spec/controllers/tournaments_controller_spec.rb +++ b/spec/controllers/tournaments_controller_spec.rb @@ -105,7 +105,7 @@ RSpec.describe TournamentsController, type: :controller do expected_keys = %i[ id name code public description playoff_teams_amount instant_finalists_amount intermediate_round_participants_amount - timer_end owner_username stages teams team_action_lists + timer_end timer_mode owner_username stages teams team_action_lists ] expect(json.keys).to match_array(expected_keys) expect(json[:name]).to eq(@tournament.name) @@ -221,14 +221,15 @@ RSpec.describe TournamentsController, type: :controller do end describe 'GET #timer_end' do - it 'returns timer_end in ISO 8601 format' do + it 'returns timer state in ISO 8601 format' do timer_end = Time.utc(2026, 4, 13, 18, 0, 0) - @tournament.update!(timer_end: timer_end) + @tournament.update!(timer_end: timer_end, timer_mode: 'countdown') get :timer_end, params: { id: @tournament.to_param } expect(response).to be_successful expect(deserialize_response(response)[:timer_end]).to eq(timer_end.iso8601) + expect(deserialize_response(response)[:timer_mode]).to eq('countdown') end end @@ -245,6 +246,37 @@ RSpec.describe TournamentsController, type: :controller do expect(response).to be_successful expect(@tournament.reload.timer_end.iso8601).to eq(Time.iso8601(timer_end).utc.iso8601) expect(deserialize_response(response)[:timer_end]).to eq(@tournament.timer_end.iso8601) + expect(deserialize_response(response)[:timer_mode]).to eq('countdown') + end + + it 'starts countup timer at current time when timer_mode=countup' do + before_request = Time.zone.now + + patch :set_timer_end, params: { id: @tournament.to_param, timer_mode: 'countup' } + + expect(response).to be_successful + expect(@tournament.reload.timer_mode).to eq('countup') + expect(@tournament.timer_end).to be_between(before_request - 1.second, Time.zone.now + 1.second) + expect(deserialize_response(response)[:timer_mode]).to eq('countup') + end + + it 'clears timer state when clear_timer=true' do + @tournament.update!(timer_end: Time.utc(2030, 4, 13, 18, 0, 0), timer_mode: 'countdown') + + patch :set_timer_end, params: { id: @tournament.to_param, clear_timer: true } + + expect(response).to be_successful + expect(@tournament.reload.timer_end).to be_nil + expect(@tournament.timer_mode).to be_nil + expect(deserialize_response(response)[:timer_end]).to be_nil + expect(deserialize_response(response)[:timer_mode]).to be_nil + end + + it 'rejects timer_end_seconds for countup mode' do + patch :set_timer_end, params: { id: @tournament.to_param, timer_mode: 'countup', timer_end_seconds: 120 } + + expect(response).to have_http_status(:unprocessable_entity) + expect(deserialize_response(response)[:error]).to eq('timer_end_seconds is only allowed for countdown mode') end end diff --git a/spec/e2e/http/api_surface_spec.rb b/spec/e2e/http/api_surface_spec.rb index 7829092..e6be9a3 100644 --- a/spec/e2e/http/api_surface_spec.rb +++ b/spec/e2e/http/api_surface_spec.rb @@ -127,12 +127,18 @@ RSpec.describe 'Backend API surface HTTP E2E' do timer_seconds = owner_client.patch("/tournaments/#{tournament.fetch(:id)}/set_timer_end", body: { timer_end_seconds: 120 }) timer_show = anonymous_client.get("/tournaments/#{tournament.fetch(:id)}/timer_end") + timer_countup = owner_client.patch("/tournaments/#{tournament.fetch(:id)}/set_timer_end", body: { timer_mode: 'countup' }) + timer_clear = owner_client.patch("/tournaments/#{tournament.fetch(:id)}/set_timer_end", body: { clear_timer: true }) timer_double = owner_client.patch("/tournaments/#{tournament.fetch(:id)}/set_timer_end", body: { timer_end_seconds: 120, timer_end: (Time.now.utc + 240).iso8601 }) timer_invalid_seconds = owner_client.patch("/tournaments/#{tournament.fetch(:id)}/set_timer_end", body: { timer_end_seconds: 'nope' }) timer_past = owner_client.patch("/tournaments/#{tournament.fetch(:id)}/set_timer_end", body: { timer_end_seconds: 0 }) + timer_countup_seconds = owner_client.patch("/tournaments/#{tournament.fetch(:id)}/set_timer_end", body: { + timer_mode: 'countup', + timer_end_seconds: 60 + }) timer_forbidden = other_client.patch("/tournaments/#{tournament.fetch(:id)}/set_timer_end", body: { timer_end_seconds: 60 }) destroy_response = owner_client.delete("/tournaments/#{tournament.fetch(:id)}") @@ -155,8 +161,16 @@ RSpec.describe 'Backend API surface HTTP E2E' do expect(timer_seconds[:status]).to eq(200) expect(timer_seconds.dig(:json, :timer_end)).to be_a(String) + expect(timer_seconds.dig(:json, :timer_mode)).to eq('countdown') expect(timer_show[:status]).to eq(200) expect(timer_show.dig(:json, :timer_end)).to eq(timer_seconds.dig(:json, :timer_end)) + expect(timer_show.dig(:json, :timer_mode)).to eq('countdown') + expect(timer_countup[:status]).to eq(200) + expect(timer_countup.dig(:json, :timer_end)).to be_a(String) + expect(timer_countup.dig(:json, :timer_mode)).to eq('countup') + expect(timer_clear[:status]).to eq(200) + expect(timer_clear.dig(:json, :timer_end)).to be_nil + expect(timer_clear.dig(:json, :timer_mode)).to be_nil expect(timer_double[:status]).to eq(422) expect(timer_double.dig(:json, :error)).to eq('Only one of timer_end or timer_end_seconds is allowed') @@ -164,6 +178,8 @@ RSpec.describe 'Backend API surface HTTP E2E' do expect(timer_invalid_seconds.dig(:json, :error)).to eq('Invalid seconds format') expect(timer_past[:status]).to eq(422) expect(timer_past.dig(:json, :error)).to eq('Timer end must be in the future') + expect(timer_countup_seconds[:status]).to eq(422) + expect(timer_countup_seconds.dig(:json, :error)).to eq('timer_end_seconds is only allowed for countdown mode') expect(timer_forbidden[:status]).to eq(403) expect([200, 204]).to include(destroy_response[:status]) diff --git a/spec/e2e/http/tournament_timer_follow_sync_websocket_spec.rb b/spec/e2e/http/tournament_timer_follow_sync_websocket_spec.rb new file mode 100644 index 0000000..2511b83 --- /dev/null +++ b/spec/e2e/http/tournament_timer_follow_sync_websocket_spec.rb @@ -0,0 +1,107 @@ +# 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 timer follower sync 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 timer websocket updates after source sync import' do + source = create_playoff_tournament(client: source_client, name_prefix: 'Ws Timer Source') + follower = create_follower_tournament(name_prefix: 'Ws Timer 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) + + cable_client = TurniereE2E::ActionCableClient.new(base_url: follower_base_url) + cable_client.subscribe_timer!(tournament_id: follower.fetch(:id)) + initial_payload = cable_client.wait_for_timer_payload! + expect(initial_payload.fetch('timer_end')).to be_nil + expect(initial_payload.fetch('timer_mode')).to be_nil + + countdown = source_client.patch("/tournaments/#{source.fetch(:id)}/set_timer_end", body: { timer_end_seconds: 120 }) + expect(countdown[:status]).to eq(200) + + countdown_payload = wait_for_timer_state!(cable_client, timer_mode: 'countdown') + expect(countdown_payload.fetch('timer_end')).to eq(countdown.dig(:json, :timer_end)) + + countup = source_client.patch("/tournaments/#{source.fetch(:id)}/set_timer_end", body: { timer_mode: 'countup' }) + expect(countup[:status]).to eq(200) + + countup_payload = wait_for_timer_state!(cable_client, timer_mode: 'countup') + expect(countup_payload.fetch('timer_end')).to eq(countup.dig(:json, :timer_end)) + + clear = source_client.patch("/tournaments/#{source.fetch(:id)}/set_timer_end", body: { clear_timer: true }) + expect(clear[:status]).to eq(200) + + cleared_payload = wait_for_timer_state!(cable_client, timer_mode: nil) + expect(cleared_payload.fetch('timer_end')).to be_nil + ensure + cable_client&.close + end + + def login_client(base_url:) + client = TurniereE2E::ApiClient.new(base_url: base_url) + response = client.login!(email: owner_email, password: owner_password) + expect(response[:status]).to eq(200) + client + end + + def create_playoff_tournament(client:, name_prefix:) + response = client.post('/tournaments', body: { + name: "#{name_prefix} #{SecureRandom.hex(3)}", + description: 'Follower websocket timer source tournament', + public: false, + teams: 4.times.map do |index| + { name: "#{name_prefix.tr(' ', '')}-#{index + 1}" } + end + }) + expect(response[:status]).to eq(201) + response.fetch(:json) + 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 websocket timer target tournament', + public: true, + read_only_mode: true, + sync_auth_token: sync_token + }) + expect(response[:status]).to eq(201) + response.fetch(:json) + end + + def wait_for_timer_state!(cable_client, timer_mode:, timeout: 20) + deadline = Time.now + timeout + loop do + payload = cable_client.wait_for_timer_payload!(timeout: [deadline - Time.now, 0.1].max) + return payload if payload.fetch('timer_mode') == timer_mode + + raise "follower timer websocket payload not received within #{timeout}s" if Time.now >= deadline + end + end +end diff --git a/spec/e2e/http/tournament_timer_websocket_spec.rb b/spec/e2e/http/tournament_timer_websocket_spec.rb new file mode 100644 index 0000000..9fd18c3 --- /dev/null +++ b/spec/e2e/http/tournament_timer_websocket_spec.rb @@ -0,0 +1,86 @@ +# 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 timer 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 timer state changes over websocket' do + tournament = create_playoff_tournament(name_prefix: 'Ws Timer') + + cable_client = TurniereE2E::ActionCableClient.new(base_url: base_url) + cable_client.subscribe_timer!(tournament_id: tournament.fetch(:id)) + + initial_payload = cable_client.wait_for_timer_payload! + expect(initial_payload.fetch('timer_end')).to be_nil + expect(initial_payload.fetch('timer_mode')).to be_nil + + countdown = client.patch("/tournaments/#{tournament.fetch(:id)}/set_timer_end", body: { timer_end_seconds: 120 }) + expect(countdown[:status]).to eq(200) + + countdown_payload = wait_for_timer_state!(cable_client, timer_mode: 'countdown') + expect(countdown_payload.fetch('timer_end')).to eq(countdown.dig(:json, :timer_end)) + + countup = client.patch("/tournaments/#{tournament.fetch(:id)}/set_timer_end", body: { timer_mode: 'countup' }) + expect(countup[:status]).to eq(200) + + countup_payload = wait_for_timer_state!(cable_client, timer_mode: 'countup') + expect(countup_payload.fetch('timer_end')).to eq(countup.dig(:json, :timer_end)) + + clear = client.patch("/tournaments/#{tournament.fetch(:id)}/set_timer_end", body: { clear_timer: true }) + expect(clear[:status]).to eq(200) + + cleared_payload = wait_for_timer_state!(cable_client, timer_mode: nil) + expect(cleared_payload.fetch('timer_end')).to be_nil + ensure + cable_client&.close + end + + def login_client + api_client = TurniereE2E::ApiClient.new(base_url: base_url) + response = api_client.login!(email: owner_email, password: owner_password) + + expect(response[:status]).to eq(200) + api_client + end + + def create_playoff_tournament(name_prefix:) + response = client.post('/tournaments', body: { + name: "#{name_prefix} #{SecureRandom.hex(3)}", + description: 'Websocket timer tournament', + public: false, + teams: 4.times.map do |index| + { name: "#{name_prefix.tr(' ', '')}-#{index + 1}" } + end + }) + expect(response[:status]).to eq(201) + response.fetch(:json) + end + + def wait_for_timer_state!(cable_client, timer_mode:, timeout: 20) + deadline = Time.now + timeout + loop do + payload = cable_client.wait_for_timer_payload!(timeout: [deadline - Time.now, 0.1].max) + return payload if payload.fetch('timer_mode') == timer_mode + + raise "timer websocket payload not received within #{timeout}s" if Time.now >= deadline + end + end +end diff --git a/spec/services/tournament_snapshot_importer_spec.rb b/spec/services/tournament_snapshot_importer_spec.rb index e315c18..25d9d0b 100644 --- a/spec/services/tournament_snapshot_importer_spec.rb +++ b/spec/services/tournament_snapshot_importer_spec.rb @@ -18,6 +18,7 @@ RSpec.describe TournamentSnapshotImporter do follower.reload expect(follower.sync_source_tournament_id).to eq(source.id) expect(follower.name).to eq(source.name) + expect(follower.timer_mode).to eq(source.timer_mode) expect(follower.teams.count).to eq(snapshot.fetch(:teams).count) expect(follower.beamers.count).to eq(snapshot.fetch(:beamers).count) expect(follower.team_action_lists.count).to eq(snapshot.fetch(:team_action_lists).count) @@ -183,6 +184,7 @@ RSpec.describe TournamentSnapshotImporter do instant_finalists_amount: tournament.instant_finalists_amount, intermediate_round_participants_amount: tournament.intermediate_round_participants_amount, timer_end: tournament.timer_end&.iso8601, + timer_mode: tournament.timer_mode, teams: tournament.teams.order(:name).map { |team| team.name }, team_action_lists: tournament.team_action_lists.order(:key).map do |list| { diff --git a/tasks.py b/tasks.py index 284773a..59e5229 100644 --- a/tasks.py +++ b/tasks.py @@ -1125,7 +1125,7 @@ def docker_blackbox_follow_test( ] 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 = _run_subprocess_live(full_command, env=env, check=False) + result = _stream_subprocess(full_command, env=env, check=False) if result.returncode != 0: raise subprocess.CalledProcessError( @@ -1696,6 +1696,7 @@ def blackbox_follow_service( spec_path=[ "spec/e2e/http/tournament_follow_sync_spec.rb", "spec/e2e/http/tournament_follow_sync_websocket_spec.rb", + "spec/e2e/http/tournament_timer_follow_sync_websocket_spec.rb", ], extra_env={ "TURNIERE_SOURCE_E2E_BASE_URL": source_base_url,