feat: add timer modes and websocket updates
This commit is contained in:
parent
e41ef870f9
commit
afff813994
|
|
@ -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
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -13,6 +13,7 @@ class TournamentSyncSchema
|
|||
instant_finalists_amount
|
||||
intermediate_round_participants_amount
|
||||
timer_end
|
||||
timer_mode
|
||||
].freeze,
|
||||
ignored: %w[
|
||||
user_id
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
@ -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
|
||||
|
|
@ -0,0 +1,5 @@
|
|||
class AddTimerModeToTournaments < ActiveRecord::Migration[7.0]
|
||||
def change
|
||||
add_column :tournaments, :timer_mode, :string
|
||||
end
|
||||
end
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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])
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
@ -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
|
||||
|
|
@ -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|
|
||||
{
|
||||
|
|
|
|||
3
tasks.py
3
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,
|
||||
|
|
|
|||
Loading…
Reference in New Issue