Merge branch 'codex/tur-55-tur-56-timer-websocket' into 'master'
TUR-55 TUR-56 feat: add timer modes and websocket updates See merge request turniere/turniere-backend!55
This commit is contained in:
commit
507cf920fe
|
|
@ -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/timer_api.md](doc/timer_api.md)
|
||||||
- [doc/beamer_live_updates.md](doc/beamer_live_updates.md)
|
- [doc/beamer_live_updates.md](doc/beamer_live_updates.md)
|
||||||
|
|
||||||
## Docker
|
## Docker
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
@ -12,15 +12,15 @@ class TournamentsController < ApplicationController
|
||||||
end
|
end
|
||||||
|
|
||||||
before_action :set_tournament_for_show, only: %i[show]
|
before_action :set_tournament_for_show, only: %i[show]
|
||||||
before_action :set_tournament, only: %i[update destroy set_timer_end timer_end sync_state test_sync]
|
before_action :set_tournament, only: %i[update destroy set_timer timer sync_state test_sync]
|
||||||
before_action :authenticate_user!, only: %i[create update destroy set_timer_end test_sync]
|
before_action :authenticate_user!, only: %i[create update destroy set_timer test_sync]
|
||||||
before_action -> { require_owner! @tournament.owner }, only: %i[update destroy set_timer_end test_sync]
|
before_action -> { require_owner! @tournament.owner }, only: %i[update destroy set_timer test_sync]
|
||||||
before_action :validate_create_params, only: %i[create]
|
before_action :validate_create_params, only: %i[create]
|
||||||
before_action :validate_update_params, only: %i[update]
|
before_action :validate_update_params, only: %i[update]
|
||||||
before_action :validate_set_timer_end_params, only: %i[set_timer_end]
|
before_action :validate_set_timer_params, only: %i[set_timer]
|
||||||
before_action :ensure_update_allowed!, only: %i[update]
|
before_action :ensure_update_allowed!, only: %i[update]
|
||||||
before_action :ensure_destroy_allowed!, only: %i[destroy]
|
before_action :ensure_destroy_allowed!, only: %i[destroy]
|
||||||
before_action :ensure_timer_update_allowed!, only: %i[set_timer_end]
|
before_action :ensure_timer_update_allowed!, only: %i[set_timer]
|
||||||
before_action :authorize_sync_request!, only: %i[sync_state]
|
before_action :authorize_sync_request!, only: %i[sync_state]
|
||||||
rescue_from ActiveRecord::RecordNotFound, with: :render_not_found_error
|
rescue_from ActiveRecord::RecordNotFound, with: :render_not_found_error
|
||||||
|
|
||||||
|
|
@ -129,14 +129,14 @@ class TournamentsController < ApplicationController
|
||||||
@tournament.destroy
|
@tournament.destroy
|
||||||
end
|
end
|
||||||
|
|
||||||
# GET /tournaments/:id/timer_end
|
# GET /tournaments/:id/timer
|
||||||
def timer_end
|
def timer
|
||||||
render json: { timer_end: serialize_timer_end(@tournament.timer_end) }
|
render json: serialize_timer_state(@tournament)
|
||||||
end
|
end
|
||||||
|
|
||||||
# PATCH /tournaments/:id/set_timer_end
|
# PATCH /tournaments/:id/timer
|
||||||
def set_timer_end
|
def set_timer
|
||||||
if @tournament.update(timer_end_params)
|
if @tournament.update(timer_params)
|
||||||
push_sync_if_needed!(@tournament)
|
push_sync_if_needed!(@tournament)
|
||||||
render json: @tournament
|
render json: @tournament
|
||||||
else
|
else
|
||||||
|
|
@ -171,12 +171,18 @@ class TournamentsController < ApplicationController
|
||||||
|
|
||||||
private
|
private
|
||||||
|
|
||||||
def timer_end_params
|
def timer_params
|
||||||
{ timer_end: params[:timer_end] }
|
{
|
||||||
|
timestamp: params[:timestamp],
|
||||||
|
timer_mode: params[:timer_mode]
|
||||||
|
}
|
||||||
end
|
end
|
||||||
|
|
||||||
def serialize_timer_end(timer_end)
|
def serialize_timer_state(tournament)
|
||||||
timer_end&.iso8601
|
{
|
||||||
|
timestamp: tournament.timestamp&.iso8601,
|
||||||
|
timer_mode: tournament.timer_mode
|
||||||
|
}
|
||||||
end
|
end
|
||||||
|
|
||||||
def organize_teams_in_groups(teams)
|
def organize_teams_in_groups(teams)
|
||||||
|
|
@ -326,41 +332,70 @@ class TournamentsController < ApplicationController
|
||||||
TournamentSyncEnqueue.call(tournament)
|
TournamentSyncEnqueue.call(tournament)
|
||||||
end
|
end
|
||||||
|
|
||||||
def validate_set_timer_end_params
|
def validate_set_timer_params
|
||||||
timer_end = params[:timer_end]
|
timestamp = params[:timestamp]
|
||||||
timer_end_seconds = params[:timer_end_seconds]
|
timestamp_seconds = params[:timestamp_seconds]
|
||||||
|
timer_mode = params[:timer_mode]
|
||||||
|
clear_timer = ActiveModel::Type::Boolean.new.cast(params[:clear_timer])
|
||||||
|
|
||||||
# throw error if both timer_end and timer_end_seconds are present
|
if clear_timer
|
||||||
if timer_end.present? && timer_end_seconds.present?
|
if timestamp.present? || timestamp_seconds.present? || timer_mode.present?
|
||||||
return render json: { error: 'Only one of timer_end or timer_end_seconds is allowed' }, status: :unprocessable_entity
|
return render json: { error: 'clear_timer cannot be combined with timestamp values or timer_mode' },
|
||||||
|
status: :unprocessable_entity
|
||||||
|
end
|
||||||
|
|
||||||
|
params[:timestamp] = nil
|
||||||
|
params[:timer_mode] = nil
|
||||||
|
return
|
||||||
end
|
end
|
||||||
|
|
||||||
if timer_end_seconds.present?
|
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 timestamp and timestamp_seconds are present
|
||||||
|
if timestamp.present? && timestamp_seconds.present?
|
||||||
|
return render json: { error: 'Only one of timestamp or timestamp_seconds is allowed' }, status: :unprocessable_entity
|
||||||
|
end
|
||||||
|
|
||||||
|
if timer_mode == 'countup' && timestamp_seconds.present?
|
||||||
|
return render json: { error: 'timestamp_seconds is only allowed for countdown mode' }, status: :unprocessable_entity
|
||||||
|
end
|
||||||
|
|
||||||
|
if timestamp_seconds.present?
|
||||||
begin
|
begin
|
||||||
timer_end_seconds = Integer(timer_end_seconds)
|
timestamp_seconds = Integer(timestamp_seconds)
|
||||||
rescue ArgumentError
|
rescue ArgumentError
|
||||||
return render json: { error: 'Invalid seconds format' }, status: :unprocessable_entity
|
return render json: { error: 'Invalid seconds format' }, status: :unprocessable_entity
|
||||||
end
|
end
|
||||||
|
|
||||||
return render json: { error: 'Timer end must be in the future' }, status: :unprocessable_entity if timer_end_seconds <= 0
|
return render json: { error: 'Timestamp must be in the future for countdown mode' }, status: :unprocessable_entity if timestamp_seconds <= 0
|
||||||
|
|
||||||
parsed_time = Time.zone.now + timer_end_seconds
|
parsed_time = Time.zone.now + timestamp_seconds
|
||||||
params[:timer_end] = parsed_time
|
params[:timestamp] = parsed_time
|
||||||
elsif timer_end.present?
|
params[:timer_mode] = 'countdown'
|
||||||
|
elsif timestamp.present?
|
||||||
begin
|
begin
|
||||||
parsed_time = Time.zone.parse(timer_end)
|
parsed_time = Time.zone.parse(timestamp)
|
||||||
if parsed_time.nil?
|
if parsed_time.nil?
|
||||||
return render json: { error: 'Invalid datetime format' }, status: :unprocessable_entity
|
return render json: { error: 'Invalid timestamp format' }, status: :unprocessable_entity
|
||||||
elsif !parsed_time.future?
|
elsif timer_mode != 'countup' && !parsed_time.future?
|
||||||
return render json: { error: 'Timer end must be in the future' }, status: :unprocessable_entity
|
return render json: { error: 'Timestamp must be in the future for countdown mode' }, status: :unprocessable_entity
|
||||||
end
|
end
|
||||||
|
|
||||||
params[:timer_end] = parsed_time
|
params[:timestamp] = parsed_time
|
||||||
|
params[:timer_mode] = timer_mode.presence || 'countdown'
|
||||||
rescue ArgumentError
|
rescue ArgumentError
|
||||||
return render json: { error: 'Invalid datetime format' }, status: :unprocessable_entity
|
return render json: { error: 'Invalid timestamp format' }, status: :unprocessable_entity
|
||||||
end
|
end
|
||||||
|
elsif timer_mode == 'countup'
|
||||||
|
params[:timestamp] = Time.zone.now
|
||||||
|
elsif timer_mode.present?
|
||||||
|
return render json: { error: 'timer_mode requires timestamp, timestamp_seconds, or clear_timer' },
|
||||||
|
status: :unprocessable_entity
|
||||||
else
|
else
|
||||||
return render json: { error: 'Timer end is required' }, status: :unprocessable_entity
|
return render json: { error: 'timestamp, timestamp_seconds, or clear_timer is required' },
|
||||||
|
status: :unprocessable_entity
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
|
||||||
|
|
@ -11,6 +11,7 @@ class Tournament < ApplicationRecord
|
||||||
|
|
||||||
validates :name, presence: true
|
validates :name, presence: true
|
||||||
validates :code, presence: true, uniqueness: 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
|
validates :sync_target_url, format: URI::DEFAULT_PARSER.make_regexp(%w[http https]), allow_blank: true
|
||||||
validate :sync_configuration_is_complete
|
validate :sync_configuration_is_complete
|
||||||
|
|
||||||
|
|
@ -21,6 +22,7 @@ class Tournament < ApplicationRecord
|
||||||
after_initialize :generate_code
|
after_initialize :generate_code
|
||||||
after_create_commit :ensure_default_beamer!
|
after_create_commit :ensure_default_beamer!
|
||||||
before_validation :clear_follower_sync_token_when_disabling_read_only_mode
|
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
|
def matches
|
||||||
[stages.map(&:matches), stages.map { |s| s.groups.map(&:matches) }].flatten
|
[stages.map(&:matches), stages.map { |s| s.groups.map(&:matches) }].flatten
|
||||||
|
|
@ -92,4 +94,12 @@ class Tournament < ApplicationRecord
|
||||||
|
|
||||||
self.sync_auth_token = nil
|
self.sync_auth_token = nil
|
||||||
end
|
end
|
||||||
|
|
||||||
|
def saved_change_to_timer_state?
|
||||||
|
saved_change_to_timestamp? || saved_change_to_timer_mode?
|
||||||
|
end
|
||||||
|
|
||||||
|
def broadcast_timer_state_change
|
||||||
|
TournamentTimerBroadcast.call(id)
|
||||||
|
end
|
||||||
end
|
end
|
||||||
|
|
|
||||||
|
|
@ -11,10 +11,12 @@ class TournamentSerializer < SimpleTournamentSerializer
|
||||||
has_many :stages
|
has_many :stages
|
||||||
has_many :team_action_lists
|
has_many :team_action_lists
|
||||||
|
|
||||||
attribute :timer_end do
|
attribute :timestamp do
|
||||||
object.timer_end&.iso8601
|
object.timestamp&.iso8601
|
||||||
end
|
end
|
||||||
|
|
||||||
|
attribute :timer_mode
|
||||||
|
|
||||||
attribute :sync_last_pushed_at, if: :sync_metadata_visible? do
|
attribute :sync_last_pushed_at, if: :sync_metadata_visible? do
|
||||||
object.sync_last_pushed_at&.iso8601
|
object.sync_last_pushed_at&.iso8601
|
||||||
end
|
end
|
||||||
|
|
|
||||||
|
|
@ -22,7 +22,8 @@ class TournamentSnapshotBuilder
|
||||||
|
|
||||||
def build
|
def build
|
||||||
payload = tournament.attributes.symbolize_keys.slice(*ROOT_ATTRIBUTES)
|
payload = tournament.attributes.symbolize_keys.slice(*ROOT_ATTRIBUTES)
|
||||||
payload[:timer_end] = tournament.timer_end&.iso8601
|
payload[:timestamp] = tournament.timestamp&.iso8601
|
||||||
|
payload[:timer_mode] = tournament.timer_mode
|
||||||
payload[:sync_generated_at] = Time.current.iso8601(6)
|
payload[:sync_generated_at] = Time.current.iso8601(6)
|
||||||
payload[:teams] = teams_payload
|
payload[:teams] = teams_payload
|
||||||
payload[:beamers] = beamers_payload
|
payload[:beamers] = beamers_payload
|
||||||
|
|
|
||||||
|
|
@ -67,7 +67,8 @@ class TournamentSnapshotImporter
|
||||||
def update_tournament!
|
def update_tournament!
|
||||||
root_attrs = snapshot.slice(*ROOT_ATTRIBUTES)
|
root_attrs = snapshot.slice(*ROOT_ATTRIBUTES)
|
||||||
root_attrs[:code] = tournament.code if code_taken_by_another_tournament?(root_attrs[:code])
|
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[:timestamp] = parse_time(snapshot[:timestamp])
|
||||||
|
root_attrs[:timer_mode] = snapshot[:timer_mode]
|
||||||
root_attrs[:sync_source_tournament_id] = snapshot[:id]
|
root_attrs[:sync_source_tournament_id] = snapshot[:id]
|
||||||
root_attrs[:sync_last_imported_snapshot_at] = snapshot_generated_at
|
root_attrs[:sync_last_imported_snapshot_at] = snapshot_generated_at
|
||||||
tournament.update!(root_attrs)
|
tournament.update!(root_attrs)
|
||||||
|
|
|
||||||
|
|
@ -12,7 +12,8 @@ class TournamentSyncSchema
|
||||||
playoff_teams_amount
|
playoff_teams_amount
|
||||||
instant_finalists_amount
|
instant_finalists_amount
|
||||||
intermediate_round_participants_amount
|
intermediate_round_participants_amount
|
||||||
timer_end
|
timestamp
|
||||||
|
timer_mode
|
||||||
].freeze,
|
].freeze,
|
||||||
ignored: %w[
|
ignored: %w[
|
||||||
user_id
|
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,
|
||||||
|
timestamp: tournament.timestamp&.iso8601,
|
||||||
|
timer_mode: tournament.timer_mode
|
||||||
|
}
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
@ -31,8 +31,8 @@ Rails.application.routes.draw do
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
member do
|
member do
|
||||||
get :timer_end
|
get :timer
|
||||||
patch :set_timer_end
|
patch :timer, action: :set_timer
|
||||||
patch :sync_state
|
patch :sync_state
|
||||||
post :test_sync
|
post :test_sync
|
||||||
end
|
end
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,5 @@
|
||||||
|
class AddTimerModeToTournaments < ActiveRecord::Migration[7.0]
|
||||||
|
def change
|
||||||
|
add_column :tournaments, :timer_mode, :string
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
@ -0,0 +1,7 @@
|
||||||
|
# frozen_string_literal: true
|
||||||
|
|
||||||
|
class RenameTimerEndToTimestampOnTournaments < ActiveRecord::Migration[7.0]
|
||||||
|
def change
|
||||||
|
rename_column :tournaments, :timer_end, :timestamp
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
@ -0,0 +1,223 @@
|
||||||
|
# This file is auto-generated from the current state of the database. Instead
|
||||||
|
# of editing this file, please use the migrations feature of Active Record to
|
||||||
|
# incrementally modify your database, and then regenerate this schema definition.
|
||||||
|
#
|
||||||
|
# This file is the source Rails uses to define your schema when running `bin/rails
|
||||||
|
# db:schema:load`. When creating a new database, `bin/rails db:schema:load` tends to
|
||||||
|
# be faster and is potentially less error prone than running all of your
|
||||||
|
# migrations from scratch. Old migrations may fail to apply correctly if those
|
||||||
|
# migrations use external dependencies or application code.
|
||||||
|
#
|
||||||
|
# It's strongly recommended that you check this file into your version control system.
|
||||||
|
|
||||||
|
ActiveRecord::Schema[7.0].define(version: 2026_04_24_120000) do
|
||||||
|
create_table "beamers", force: :cascade do |t|
|
||||||
|
t.string "name", null: false
|
||||||
|
t.string "display_state", default: "matches_all", null: false
|
||||||
|
t.boolean "is_default", default: false
|
||||||
|
t.string "qr_code_url"
|
||||||
|
t.string "logo_url"
|
||||||
|
t.integer "tournament_id", null: false
|
||||||
|
t.datetime "created_at", null: false
|
||||||
|
t.datetime "updated_at", null: false
|
||||||
|
t.integer "sync_source_id"
|
||||||
|
t.index ["tournament_id", "name"], name: "index_beamers_on_tournament_id_and_name", unique: true
|
||||||
|
t.index ["tournament_id"], name: "index_beamers_on_tournament_id"
|
||||||
|
end
|
||||||
|
|
||||||
|
create_table "bets", force: :cascade do |t|
|
||||||
|
t.integer "user_id", null: false
|
||||||
|
t.integer "match_id", null: false
|
||||||
|
t.integer "team_id"
|
||||||
|
t.datetime "created_at", precision: nil, null: false
|
||||||
|
t.datetime "updated_at", precision: nil, null: false
|
||||||
|
t.index ["match_id"], name: "index_bets_on_match_id"
|
||||||
|
t.index ["team_id"], name: "index_bets_on_team_id"
|
||||||
|
t.index ["user_id"], name: "index_bets_on_user_id"
|
||||||
|
end
|
||||||
|
|
||||||
|
create_table "group_scores", force: :cascade do |t|
|
||||||
|
t.integer "group_points", default: 0
|
||||||
|
t.integer "scored_points", default: 0
|
||||||
|
t.integer "received_points", default: 0
|
||||||
|
t.integer "team_id", null: false
|
||||||
|
t.integer "group_id", null: false
|
||||||
|
t.datetime "created_at", precision: nil, null: false
|
||||||
|
t.datetime "updated_at", precision: nil, null: false
|
||||||
|
t.integer "position", default: 0
|
||||||
|
t.integer "sync_source_id"
|
||||||
|
t.index ["group_id"], name: "index_group_scores_on_group_id"
|
||||||
|
t.index ["team_id"], name: "index_group_scores_on_team_id"
|
||||||
|
end
|
||||||
|
|
||||||
|
create_table "groups", force: :cascade do |t|
|
||||||
|
t.integer "number"
|
||||||
|
t.integer "stage_id", null: false
|
||||||
|
t.datetime "created_at", precision: nil, null: false
|
||||||
|
t.datetime "updated_at", precision: nil, null: false
|
||||||
|
t.integer "sync_source_id"
|
||||||
|
t.index ["stage_id"], name: "index_groups_on_stage_id"
|
||||||
|
end
|
||||||
|
|
||||||
|
create_table "match_scores", force: :cascade do |t|
|
||||||
|
t.integer "points", default: 0
|
||||||
|
t.integer "match_id", null: false
|
||||||
|
t.integer "team_id", null: false
|
||||||
|
t.datetime "created_at", precision: nil, null: false
|
||||||
|
t.datetime "updated_at", precision: nil, null: false
|
||||||
|
t.integer "hidden_points", default: 0, null: false
|
||||||
|
t.integer "sync_source_id"
|
||||||
|
t.index ["match_id"], name: "index_match_scores_on_match_id"
|
||||||
|
t.index ["team_id"], name: "index_match_scores_on_team_id"
|
||||||
|
end
|
||||||
|
|
||||||
|
create_table "matches", force: :cascade do |t|
|
||||||
|
t.integer "state", default: 0
|
||||||
|
t.integer "position"
|
||||||
|
t.integer "stage_id"
|
||||||
|
t.integer "group_id"
|
||||||
|
t.datetime "created_at", precision: nil, null: false
|
||||||
|
t.datetime "updated_at", precision: nil, null: false
|
||||||
|
t.boolean "decider", default: false, null: false
|
||||||
|
t.integer "sync_source_id"
|
||||||
|
t.index ["group_id"], name: "index_matches_on_group_id"
|
||||||
|
t.index ["stage_id"], name: "index_matches_on_stage_id"
|
||||||
|
end
|
||||||
|
|
||||||
|
create_table "stages", force: :cascade do |t|
|
||||||
|
t.integer "level"
|
||||||
|
t.integer "state", default: 0
|
||||||
|
t.integer "tournament_id", null: false
|
||||||
|
t.datetime "created_at", precision: nil, null: false
|
||||||
|
t.datetime "updated_at", precision: nil, null: false
|
||||||
|
t.integer "sync_source_id"
|
||||||
|
t.index ["tournament_id"], name: "index_stages_on_tournament_id"
|
||||||
|
end
|
||||||
|
|
||||||
|
create_table "team_action_items", force: :cascade do |t|
|
||||||
|
t.integer "team_action_list_id", null: false
|
||||||
|
t.integer "team_id", null: false
|
||||||
|
t.boolean "completed", default: false, null: false
|
||||||
|
t.datetime "completed_at"
|
||||||
|
t.integer "sync_source_id"
|
||||||
|
t.datetime "created_at", null: false
|
||||||
|
t.datetime "updated_at", null: false
|
||||||
|
t.index ["sync_source_id"], name: "index_team_action_items_on_sync_source_id"
|
||||||
|
t.index ["team_action_list_id", "team_id"], name: "index_team_action_items_on_team_action_list_id_and_team_id", unique: true
|
||||||
|
t.index ["team_action_list_id"], name: "index_team_action_items_on_team_action_list_id"
|
||||||
|
t.index ["team_id"], name: "index_team_action_items_on_team_id"
|
||||||
|
end
|
||||||
|
|
||||||
|
create_table "team_action_lists", force: :cascade do |t|
|
||||||
|
t.integer "tournament_id", null: false
|
||||||
|
t.string "name", null: false
|
||||||
|
t.string "key", null: false
|
||||||
|
t.string "action_name", null: false
|
||||||
|
t.string "source", null: false
|
||||||
|
t.integer "sync_source_id"
|
||||||
|
t.datetime "created_at", null: false
|
||||||
|
t.datetime "updated_at", null: false
|
||||||
|
t.index ["sync_source_id"], name: "index_team_action_lists_on_sync_source_id"
|
||||||
|
t.index ["tournament_id", "key"], name: "index_team_action_lists_on_tournament_id_and_key", unique: true
|
||||||
|
t.index ["tournament_id"], name: "index_team_action_lists_on_tournament_id"
|
||||||
|
end
|
||||||
|
|
||||||
|
create_table "teams", force: :cascade do |t|
|
||||||
|
t.string "name"
|
||||||
|
t.integer "tournament_id"
|
||||||
|
t.datetime "created_at", precision: nil, null: false
|
||||||
|
t.datetime "updated_at", precision: nil, null: false
|
||||||
|
t.integer "sync_source_id"
|
||||||
|
t.index ["tournament_id"], name: "index_teams_on_tournament_id"
|
||||||
|
end
|
||||||
|
|
||||||
|
create_table "tournament_sync_queue_entries", force: :cascade do |t|
|
||||||
|
t.integer "tournament_id", null: false
|
||||||
|
t.text "snapshot_json", null: false
|
||||||
|
t.datetime "next_attempt_at", null: false
|
||||||
|
t.datetime "last_attempt_at"
|
||||||
|
t.datetime "locked_at"
|
||||||
|
t.string "lock_token"
|
||||||
|
t.integer "attempts", default: 0, null: false
|
||||||
|
t.string "status", default: "pending", null: false
|
||||||
|
t.string "last_error"
|
||||||
|
t.datetime "created_at", null: false
|
||||||
|
t.datetime "updated_at", null: false
|
||||||
|
t.index ["next_attempt_at"], name: "index_tournament_sync_queue_entries_on_next_attempt_at"
|
||||||
|
t.index ["status"], name: "index_tournament_sync_queue_entries_on_status"
|
||||||
|
t.index ["tournament_id"], name: "index_tournament_sync_queue_entries_on_tournament_id", unique: true
|
||||||
|
end
|
||||||
|
|
||||||
|
create_table "tournaments", force: :cascade do |t|
|
||||||
|
t.string "name", null: false
|
||||||
|
t.string "code", null: false
|
||||||
|
t.string "description"
|
||||||
|
t.boolean "public", default: true
|
||||||
|
t.integer "playoff_teams_amount", default: 0
|
||||||
|
t.integer "instant_finalists_amount", default: 0
|
||||||
|
t.integer "intermediate_round_participants_amount", default: 0
|
||||||
|
t.integer "user_id", null: false
|
||||||
|
t.datetime "created_at", precision: nil, null: false
|
||||||
|
t.datetime "updated_at", precision: nil, null: false
|
||||||
|
t.datetime "timestamp"
|
||||||
|
t.boolean "read_only_mode", default: false, null: false
|
||||||
|
t.string "sync_target_url"
|
||||||
|
t.string "sync_auth_token"
|
||||||
|
t.integer "sync_source_tournament_id"
|
||||||
|
t.datetime "sync_last_pushed_at"
|
||||||
|
t.string "sync_last_push_error"
|
||||||
|
t.datetime "sync_last_imported_snapshot_at"
|
||||||
|
t.string "timer_mode"
|
||||||
|
t.index ["code"], name: "index_tournaments_on_code", unique: true
|
||||||
|
t.index ["user_id"], name: "index_tournaments_on_user_id"
|
||||||
|
end
|
||||||
|
|
||||||
|
create_table "users", force: :cascade do |t|
|
||||||
|
t.string "provider", default: "email", null: false
|
||||||
|
t.string "uid", default: "", null: false
|
||||||
|
t.string "encrypted_password", default: "", null: false
|
||||||
|
t.string "reset_password_token"
|
||||||
|
t.datetime "reset_password_sent_at", precision: nil
|
||||||
|
t.boolean "allow_password_change", default: false
|
||||||
|
t.datetime "remember_created_at", precision: nil
|
||||||
|
t.integer "sign_in_count", default: 0, null: false
|
||||||
|
t.datetime "current_sign_in_at", precision: nil
|
||||||
|
t.datetime "last_sign_in_at", precision: nil
|
||||||
|
t.string "current_sign_in_ip"
|
||||||
|
t.string "last_sign_in_ip"
|
||||||
|
t.string "confirmation_token"
|
||||||
|
t.datetime "confirmed_at", precision: nil
|
||||||
|
t.datetime "confirmation_sent_at", precision: nil
|
||||||
|
t.string "unconfirmed_email"
|
||||||
|
t.string "username"
|
||||||
|
t.string "email"
|
||||||
|
t.text "tokens"
|
||||||
|
t.datetime "created_at", precision: nil, null: false
|
||||||
|
t.datetime "updated_at", precision: nil, null: false
|
||||||
|
t.index ["confirmation_token"], name: "index_users_on_confirmation_token", unique: true
|
||||||
|
t.index ["email"], name: "index_users_on_email", unique: true
|
||||||
|
t.index ["provider"], name: "index_users_on_provider"
|
||||||
|
t.index ["reset_password_token"], name: "index_users_on_reset_password_token", unique: true
|
||||||
|
t.index ["uid"], name: "index_users_on_uid", unique: true
|
||||||
|
t.index ["username"], name: "index_users_on_username", unique: true
|
||||||
|
end
|
||||||
|
|
||||||
|
add_foreign_key "beamers", "tournaments", on_delete: :cascade
|
||||||
|
add_foreign_key "bets", "matches", on_delete: :cascade
|
||||||
|
add_foreign_key "bets", "teams", on_delete: :cascade
|
||||||
|
add_foreign_key "bets", "users", on_delete: :cascade
|
||||||
|
add_foreign_key "group_scores", "groups", on_delete: :cascade
|
||||||
|
add_foreign_key "group_scores", "teams", on_delete: :cascade
|
||||||
|
add_foreign_key "groups", "stages", on_delete: :cascade
|
||||||
|
add_foreign_key "match_scores", "matches", on_delete: :cascade
|
||||||
|
add_foreign_key "match_scores", "teams", on_delete: :cascade
|
||||||
|
add_foreign_key "matches", "groups", on_delete: :cascade
|
||||||
|
add_foreign_key "matches", "stages", on_delete: :cascade
|
||||||
|
add_foreign_key "stages", "tournaments", on_delete: :cascade
|
||||||
|
add_foreign_key "team_action_items", "team_action_lists", on_delete: :cascade
|
||||||
|
add_foreign_key "team_action_items", "teams", on_delete: :cascade
|
||||||
|
add_foreign_key "team_action_lists", "tournaments", on_delete: :cascade
|
||||||
|
add_foreign_key "teams", "tournaments", on_delete: :cascade
|
||||||
|
add_foreign_key "tournament_sync_queue_entries", "tournaments", on_delete: :cascade
|
||||||
|
add_foreign_key "tournaments", "users", on_delete: :cascade
|
||||||
|
end
|
||||||
|
|
@ -0,0 +1,325 @@
|
||||||
|
# Tournament Timer API
|
||||||
|
|
||||||
|
## Purpose
|
||||||
|
|
||||||
|
Backend timer supports:
|
||||||
|
|
||||||
|
- countdown mode
|
||||||
|
- countup mode
|
||||||
|
- clear / unset
|
||||||
|
- live websocket updates
|
||||||
|
- follower sync propagation
|
||||||
|
|
||||||
|
Frontend should treat timer state as:
|
||||||
|
|
||||||
|
- no timer: `timestamp = null`, `timer_mode = null`
|
||||||
|
- countdown: `timer_mode = "countdown"`
|
||||||
|
- countup: `timer_mode = "countup"`
|
||||||
|
|
||||||
|
## Read Flow
|
||||||
|
|
||||||
|
Timer state is available in normal tournament payload:
|
||||||
|
|
||||||
|
- `GET /tournaments/:id`
|
||||||
|
|
||||||
|
Dedicated timer endpoint also exists:
|
||||||
|
|
||||||
|
- `GET /tournaments/:id/timer`
|
||||||
|
|
||||||
|
Response shape:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"timestamp": "2026-04-24T10:15:00Z",
|
||||||
|
"timer_mode": "countdown"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Possible values:
|
||||||
|
|
||||||
|
- `timer_mode = "countdown"`
|
||||||
|
- `timer_mode = "countup"`
|
||||||
|
- `timer_mode = null`
|
||||||
|
|
||||||
|
## Meaning Of `timestamp`
|
||||||
|
|
||||||
|
Same field. Mode decides meaning.
|
||||||
|
|
||||||
|
### Countdown mode
|
||||||
|
|
||||||
|
`timestamp` means exact wall-clock timestamp when countdown reaches zero.
|
||||||
|
|
||||||
|
Frontend math:
|
||||||
|
|
||||||
|
```text
|
||||||
|
remaining_ms = max(0, timestamp - now)
|
||||||
|
```
|
||||||
|
|
||||||
|
### Countup mode
|
||||||
|
|
||||||
|
`timestamp` means anchor timestamp when countup started.
|
||||||
|
|
||||||
|
It is **not** finish time in this mode.
|
||||||
|
Think of it as:
|
||||||
|
|
||||||
|
- timer start time
|
||||||
|
- elapsed-time anchor
|
||||||
|
|
||||||
|
Frontend math:
|
||||||
|
|
||||||
|
```text
|
||||||
|
elapsed_ms = max(0, now - timestamp)
|
||||||
|
```
|
||||||
|
|
||||||
|
Practical example:
|
||||||
|
|
||||||
|
- backend returns:
|
||||||
|
- `timer_mode = "countup"`
|
||||||
|
- `timestamp = "2026-04-24T10:00:00Z"`
|
||||||
|
- current time:
|
||||||
|
- `2026-04-24T10:03:15Z`
|
||||||
|
|
||||||
|
Frontend should display about `03:15` elapsed.
|
||||||
|
|
||||||
|
### Unset timer
|
||||||
|
|
||||||
|
- `timer_mode = null`
|
||||||
|
- `timestamp = null`
|
||||||
|
|
||||||
|
Frontend should render no active timer.
|
||||||
|
|
||||||
|
## Write Flow
|
||||||
|
|
||||||
|
Only tournament owner may change timer.
|
||||||
|
Follower tournaments in `read_only_mode` reject writes with `423`.
|
||||||
|
|
||||||
|
Endpoint:
|
||||||
|
|
||||||
|
- `PATCH /tournaments/:id/timer`
|
||||||
|
|
||||||
|
### Start countdown by duration
|
||||||
|
|
||||||
|
Request:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"timestamp_seconds": 120
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Behavior:
|
||||||
|
|
||||||
|
- backend computes `Time.zone.now + 120`
|
||||||
|
- backend stores:
|
||||||
|
- `timer_mode = "countdown"`
|
||||||
|
- `timestamp = computed future timestamp`
|
||||||
|
|
||||||
|
### Start countdown by explicit timestamp
|
||||||
|
|
||||||
|
Request:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"timestamp": "2026-04-24T10:15:00Z"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Optional explicit mode:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"timestamp": "2026-04-24T10:15:00Z",
|
||||||
|
"timer_mode": "countdown"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Start countup
|
||||||
|
|
||||||
|
Request:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"timer_mode": "countup"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Behavior:
|
||||||
|
|
||||||
|
- backend stores current server time into `timestamp`
|
||||||
|
- backend stores `timer_mode = "countup"`
|
||||||
|
|
||||||
|
Frontend should interpret returned `timestamp` as start time anchor.
|
||||||
|
|
||||||
|
### Restore countup from explicit anchor
|
||||||
|
|
||||||
|
Backend also accepts explicit timestamp with countup mode:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"timestamp": "2026-04-24T10:00:00Z",
|
||||||
|
"timer_mode": "countup"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Useful for:
|
||||||
|
|
||||||
|
- restoring imported state
|
||||||
|
- rehydrating synced follower state
|
||||||
|
- advanced admin tooling
|
||||||
|
|
||||||
|
### Clear timer
|
||||||
|
|
||||||
|
Request:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"clear_timer": true
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Behavior:
|
||||||
|
|
||||||
|
- backend clears both fields
|
||||||
|
- response returns:
|
||||||
|
- `timestamp = null`
|
||||||
|
- `timer_mode = null`
|
||||||
|
|
||||||
|
## Validation Rules
|
||||||
|
|
||||||
|
Backend rejects:
|
||||||
|
|
||||||
|
- both `timestamp` and `timestamp_seconds`
|
||||||
|
- invalid `timer_mode`
|
||||||
|
- `timestamp_seconds <= 0`
|
||||||
|
- `timestamp_seconds` together with `timer_mode = "countup"`
|
||||||
|
- `timer_mode` by itself unless it is `countup`
|
||||||
|
- `clear_timer` mixed with timer values
|
||||||
|
|
||||||
|
Countdown timestamps must be future timestamps.
|
||||||
|
Countup timestamps may be current or past timestamps.
|
||||||
|
|
||||||
|
## Websocket / Live Updates
|
||||||
|
|
||||||
|
Timer uses ActionCable websocket broadcast.
|
||||||
|
This is websocket push, not webhook callback.
|
||||||
|
|
||||||
|
Channel:
|
||||||
|
|
||||||
|
- `TournamentTimersChannel`
|
||||||
|
|
||||||
|
Subscription identifier:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"channel": "TournamentTimersChannel",
|
||||||
|
"tournament_id": 123
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Payload:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"type": "timer.updated",
|
||||||
|
"tournament_id": 123,
|
||||||
|
"timestamp": "2026-04-24T10:15:00Z",
|
||||||
|
"timer_mode": "countdown"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Behavior:
|
||||||
|
|
||||||
|
- subscription immediately receives current timer snapshot
|
||||||
|
- later timer changes broadcast full replacement snapshot
|
||||||
|
- direct owner edits and follower sync imports both use same broadcast path
|
||||||
|
|
||||||
|
Frontend rule:
|
||||||
|
|
||||||
|
- replace local timer state from websocket payload
|
||||||
|
- do not merge by guessing transitions
|
||||||
|
|
||||||
|
## Frontend Implementation Guide
|
||||||
|
|
||||||
|
Recommended flow:
|
||||||
|
|
||||||
|
1. Load tournament via `GET /tournaments/:id`
|
||||||
|
2. Read `timer_mode` and `timestamp`
|
||||||
|
3. Open ActionCable subscription for same tournament id
|
||||||
|
4. Replace local timer state whenever `timer.updated` arrives
|
||||||
|
5. Derive displayed time from `Date.now()` plus stored backend timestamp
|
||||||
|
|
||||||
|
Recommended client state:
|
||||||
|
|
||||||
|
```ts
|
||||||
|
type TimerState =
|
||||||
|
| { mode: null; timestampIso: null }
|
||||||
|
| { mode: "countdown"; timestampIso: string }
|
||||||
|
| { mode: "countup"; timestampIso: string };
|
||||||
|
```
|
||||||
|
|
||||||
|
Suggested derivation logic:
|
||||||
|
|
||||||
|
```ts
|
||||||
|
function getTimerDisplay(state: TimerState, nowMs: number) {
|
||||||
|
if (state.mode === null || state.timestampIso === null) {
|
||||||
|
return { active: false, ms: 0 };
|
||||||
|
}
|
||||||
|
|
||||||
|
const timestampMs = new Date(state.timestampIso).getTime();
|
||||||
|
|
||||||
|
if (state.mode === "countdown") {
|
||||||
|
return { active: true, ms: Math.max(0, timestampMs - nowMs) };
|
||||||
|
}
|
||||||
|
|
||||||
|
return { active: true, ms: Math.max(0, nowMs - timestampMs) };
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Recommended rendering:
|
||||||
|
|
||||||
|
- `countdown`: show remaining time
|
||||||
|
- `countup`: show elapsed time
|
||||||
|
- unset: hide timer or show inactive state
|
||||||
|
|
||||||
|
Recommended local ticking:
|
||||||
|
|
||||||
|
- keep backend state as timestamp + mode
|
||||||
|
- update rendered value with local interval
|
||||||
|
- do not poll backend every second
|
||||||
|
|
||||||
|
Recommended write UX:
|
||||||
|
|
||||||
|
- owner actions send explicit target request
|
||||||
|
- after success, update local state from response body
|
||||||
|
- websocket should converge all viewers to same state
|
||||||
|
|
||||||
|
## Follower Behavior
|
||||||
|
|
||||||
|
Timer state is included in tournament sync snapshots.
|
||||||
|
|
||||||
|
That means:
|
||||||
|
|
||||||
|
- leader timer changes propagate to follower
|
||||||
|
- follower tournament exposes same `timestamp` / `timer_mode`
|
||||||
|
- follower websocket subscribers receive `timer.updated`
|
||||||
|
- follower still rejects timer writes while read only
|
||||||
|
|
||||||
|
## E2E Coverage
|
||||||
|
|
||||||
|
Implemented coverage:
|
||||||
|
|
||||||
|
- API surface timer lifecycle:
|
||||||
|
- countdown
|
||||||
|
- countup
|
||||||
|
- clear
|
||||||
|
- validation
|
||||||
|
- [spec/e2e/http/api_surface_spec.rb](../spec/e2e/http/api_surface_spec.rb)
|
||||||
|
- direct websocket timer updates:
|
||||||
|
- [spec/e2e/http/tournament_timer_websocket_spec.rb](../spec/e2e/http/tournament_timer_websocket_spec.rb)
|
||||||
|
- follower websocket timer propagation:
|
||||||
|
- [spec/e2e/http/tournament_timer_follow_sync_websocket_spec.rb](../spec/e2e/http/tournament_timer_follow_sync_websocket_spec.rb)
|
||||||
|
|
||||||
|
Current note:
|
||||||
|
|
||||||
|
- single-backend HTTP E2E timer specs pass
|
||||||
|
- follower blackbox task still has existing Docker/network issue in follow setup, so production-style follow stack verification is not green yet
|
||||||
|
|
@ -27,6 +27,17 @@ module TurniereE2E
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
|
def subscribe_timer!(tournament_id:)
|
||||||
|
identifier = {
|
||||||
|
channel: 'TournamentTimersChannel',
|
||||||
|
tournament_id: tournament_id
|
||||||
|
}.to_json
|
||||||
|
|
||||||
|
subscribe!(identifier:) do |message|
|
||||||
|
timer_payload_message?(message)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
def subscribe_beamer!(tournament_id:, beamer_name:)
|
def subscribe_beamer!(tournament_id:, beamer_name:)
|
||||||
identifier = {
|
identifier = {
|
||||||
channel: 'TournamentBeamersChannel',
|
channel: 'TournamentBeamersChannel',
|
||||||
|
|
@ -41,8 +52,13 @@ module TurniereE2E
|
||||||
|
|
||||||
def wait_for_payload!(timeout: 20)
|
def wait_for_payload!(timeout: 20)
|
||||||
wait_for!(timeout: timeout) do |message|
|
wait_for!(timeout: timeout) do |message|
|
||||||
payload = message['message']
|
team_action_lists_payload_message?(message)
|
||||||
payload.is_a?(Hash) && payload['type'] == 'team_action_lists.updated'
|
end.fetch('message')
|
||||||
|
end
|
||||||
|
|
||||||
|
def wait_for_timer_payload!(timeout: 20)
|
||||||
|
wait_for!(timeout: timeout) do |message|
|
||||||
|
timer_payload_message?(message)
|
||||||
end.fetch('message')
|
end.fetch('message')
|
||||||
end
|
end
|
||||||
|
|
||||||
|
|
@ -169,6 +185,11 @@ module TurniereE2E
|
||||||
payload.is_a?(Hash) && payload['type'] == 'team_action_lists.updated'
|
payload.is_a?(Hash) && payload['type'] == 'team_action_lists.updated'
|
||||||
end
|
end
|
||||||
|
|
||||||
|
def timer_payload_message?(message)
|
||||||
|
payload = message['message']
|
||||||
|
payload.is_a?(Hash) && payload['type'] == 'timer.updated'
|
||||||
|
end
|
||||||
|
|
||||||
def beamer_payload_message?(message)
|
def beamer_payload_message?(message)
|
||||||
payload = message['message']
|
payload = message['message']
|
||||||
payload.is_a?(Hash) && payload['type'].to_s.start_with?('beamer.')
|
payload.is_a?(Hash) && payload['type'].to_s.start_with?('beamer.')
|
||||||
|
|
|
||||||
|
|
@ -29,10 +29,10 @@ module TurniereE2E
|
||||||
}
|
}
|
||||||
|
|
||||||
timer_response = patch!(
|
timer_response = patch!(
|
||||||
"/tournaments/#{tournament.fetch(:id)}/set_timer_end",
|
"/tournaments/#{tournament.fetch(:id)}/timer",
|
||||||
body: { timer_end_seconds: 600 }
|
body: { timestamp_seconds: 600 }
|
||||||
)
|
)
|
||||||
checkpoints[:timer_set] = { timer_end: timer_response.fetch(:timer_end) }
|
checkpoints[:timer_set] = { timestamp: timer_response.fetch(:timestamp) }
|
||||||
return result(session: session, tournament: tournament, checkpoints: checkpoints, stop_at: stop_at) if stop_at == :timer_set
|
return result(session: session, tournament: tournament, checkpoints: checkpoints, stop_at: stop_at) if stop_at == :timer_set
|
||||||
|
|
||||||
upcoming_matches = get!(
|
upcoming_matches = get!(
|
||||||
|
|
|
||||||
|
|
@ -105,7 +105,7 @@ RSpec.describe TournamentsController, type: :controller do
|
||||||
expected_keys = %i[
|
expected_keys = %i[
|
||||||
id name code public description playoff_teams_amount
|
id name code public description playoff_teams_amount
|
||||||
instant_finalists_amount intermediate_round_participants_amount
|
instant_finalists_amount intermediate_round_participants_amount
|
||||||
timer_end owner_username stages teams team_action_lists
|
timestamp timer_mode owner_username stages teams team_action_lists
|
||||||
]
|
]
|
||||||
expect(json.keys).to match_array(expected_keys)
|
expect(json.keys).to match_array(expected_keys)
|
||||||
expect(json[:name]).to eq(@tournament.name)
|
expect(json[:name]).to eq(@tournament.name)
|
||||||
|
|
@ -220,31 +220,63 @@ RSpec.describe TournamentsController, type: :controller do
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
describe 'GET #timer_end' do
|
describe 'GET #timer' 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)
|
timestamp = Time.utc(2026, 4, 13, 18, 0, 0)
|
||||||
@tournament.update!(timer_end: timer_end)
|
@tournament.update!(timestamp: timestamp, timer_mode: 'countdown')
|
||||||
|
|
||||||
get :timer_end, params: { id: @tournament.to_param }
|
get :timer, params: { id: @tournament.to_param }
|
||||||
|
|
||||||
expect(response).to be_successful
|
expect(response).to be_successful
|
||||||
expect(deserialize_response(response)[:timer_end]).to eq(timer_end.iso8601)
|
expect(deserialize_response(response)[:timestamp]).to eq(timestamp.iso8601)
|
||||||
|
expect(deserialize_response(response)[:timer_mode]).to eq('countdown')
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
describe 'PATCH #set_timer_end' do
|
describe 'PATCH #set_timer' do
|
||||||
before(:each) do
|
before(:each) do
|
||||||
apply_authentication_headers_for @user
|
apply_authentication_headers_for @user
|
||||||
end
|
end
|
||||||
|
|
||||||
it 'stores timezone-aware timer_end values without shifting them' do
|
it 'stores timezone-aware timestamp values without shifting them' do
|
||||||
timer_end = '2030-04-13T18:00:00+02:00'
|
timestamp = '2030-04-13T18:00:00+02:00'
|
||||||
|
|
||||||
patch :set_timer_end, params: { id: @tournament.to_param, timer_end: timer_end }
|
patch :set_timer, params: { id: @tournament.to_param, timestamp: timestamp }
|
||||||
|
|
||||||
expect(response).to be_successful
|
expect(response).to be_successful
|
||||||
expect(@tournament.reload.timer_end.iso8601).to eq(Time.iso8601(timer_end).utc.iso8601)
|
expect(@tournament.reload.timestamp.iso8601).to eq(Time.iso8601(timestamp).utc.iso8601)
|
||||||
expect(deserialize_response(response)[:timer_end]).to eq(@tournament.timer_end.iso8601)
|
expect(deserialize_response(response)[:timestamp]).to eq(@tournament.timestamp.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, params: { id: @tournament.to_param, timer_mode: 'countup' }
|
||||||
|
|
||||||
|
expect(response).to be_successful
|
||||||
|
expect(@tournament.reload.timer_mode).to eq('countup')
|
||||||
|
expect(@tournament.timestamp).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!(timestamp: Time.utc(2030, 4, 13, 18, 0, 0), timer_mode: 'countdown')
|
||||||
|
|
||||||
|
patch :set_timer, params: { id: @tournament.to_param, clear_timer: true }
|
||||||
|
|
||||||
|
expect(response).to be_successful
|
||||||
|
expect(@tournament.reload.timestamp).to be_nil
|
||||||
|
expect(@tournament.timer_mode).to be_nil
|
||||||
|
expect(deserialize_response(response)[:timestamp]).to be_nil
|
||||||
|
expect(deserialize_response(response)[:timer_mode]).to be_nil
|
||||||
|
end
|
||||||
|
|
||||||
|
it 'rejects timestamp_seconds for countup mode' do
|
||||||
|
patch :set_timer, params: { id: @tournament.to_param, timer_mode: 'countup', timestamp_seconds: 120 }
|
||||||
|
|
||||||
|
expect(response).to have_http_status(:unprocessable_entity)
|
||||||
|
expect(deserialize_response(response)[:error]).to eq('timestamp_seconds is only allowed for countdown mode')
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
|
|
@ -710,7 +742,7 @@ RSpec.describe TournamentsController, type: :controller do
|
||||||
playoff_teams_amount: 4,
|
playoff_teams_amount: 4,
|
||||||
instant_finalists_amount: 4,
|
instant_finalists_amount: 4,
|
||||||
intermediate_round_participants_amount: 0,
|
intermediate_round_participants_amount: 0,
|
||||||
timer_end: nil,
|
timestamp: nil,
|
||||||
teams: [{ id: 7, name: 'Alpha' }],
|
teams: [{ id: 7, name: 'Alpha' }],
|
||||||
stages: [],
|
stages: [],
|
||||||
groups: [],
|
groups: [],
|
||||||
|
|
|
||||||
|
|
@ -125,15 +125,21 @@ RSpec.describe 'Backend API surface HTTP E2E' do
|
||||||
forbidden_update = other_client.patch("/tournaments/#{tournament.fetch(:id)}", body: { name: 'Not Allowed' })
|
forbidden_update = other_client.patch("/tournaments/#{tournament.fetch(:id)}", body: { name: 'Not Allowed' })
|
||||||
unauthenticated_update = anonymous_client.patch("/tournaments/#{tournament.fetch(:id)}", body: { name: 'No Auth' })
|
unauthenticated_update = anonymous_client.patch("/tournaments/#{tournament.fetch(:id)}", body: { name: 'No Auth' })
|
||||||
|
|
||||||
timer_seconds = owner_client.patch("/tournaments/#{tournament.fetch(:id)}/set_timer_end", body: { timer_end_seconds: 120 })
|
timer_seconds = owner_client.patch("/tournaments/#{tournament.fetch(:id)}/timer", body: { timestamp_seconds: 120 })
|
||||||
timer_show = anonymous_client.get("/tournaments/#{tournament.fetch(:id)}/timer_end")
|
timer_show = anonymous_client.get("/tournaments/#{tournament.fetch(:id)}/timer")
|
||||||
timer_double = owner_client.patch("/tournaments/#{tournament.fetch(:id)}/set_timer_end", body: {
|
timer_countup = owner_client.patch("/tournaments/#{tournament.fetch(:id)}/timer", body: { timer_mode: 'countup' })
|
||||||
timer_end_seconds: 120,
|
timer_clear = owner_client.patch("/tournaments/#{tournament.fetch(:id)}/timer", body: { clear_timer: true })
|
||||||
timer_end: (Time.now.utc + 240).iso8601
|
timer_double = owner_client.patch("/tournaments/#{tournament.fetch(:id)}/timer", body: {
|
||||||
|
timestamp_seconds: 120,
|
||||||
|
timestamp: (Time.now.utc + 240).iso8601
|
||||||
})
|
})
|
||||||
timer_invalid_seconds = owner_client.patch("/tournaments/#{tournament.fetch(:id)}/set_timer_end", body: { timer_end_seconds: 'nope' })
|
timer_invalid_seconds = owner_client.patch("/tournaments/#{tournament.fetch(:id)}/timer", body: { timestamp_seconds: 'nope' })
|
||||||
timer_past = owner_client.patch("/tournaments/#{tournament.fetch(:id)}/set_timer_end", body: { timer_end_seconds: 0 })
|
timer_past = owner_client.patch("/tournaments/#{tournament.fetch(:id)}/timer", body: { timestamp_seconds: 0 })
|
||||||
timer_forbidden = other_client.patch("/tournaments/#{tournament.fetch(:id)}/set_timer_end", body: { timer_end_seconds: 60 })
|
timer_countup_seconds = owner_client.patch("/tournaments/#{tournament.fetch(:id)}/timer", body: {
|
||||||
|
timer_mode: 'countup',
|
||||||
|
timestamp_seconds: 60
|
||||||
|
})
|
||||||
|
timer_forbidden = other_client.patch("/tournaments/#{tournament.fetch(:id)}/timer", body: { timestamp_seconds: 60 })
|
||||||
|
|
||||||
destroy_response = owner_client.delete("/tournaments/#{tournament.fetch(:id)}")
|
destroy_response = owner_client.delete("/tournaments/#{tournament.fetch(:id)}")
|
||||||
after_destroy = anonymous_client.get("/tournaments/#{tournament.fetch(:id)}")
|
after_destroy = anonymous_client.get("/tournaments/#{tournament.fetch(:id)}")
|
||||||
|
|
@ -154,16 +160,26 @@ RSpec.describe 'Backend API surface HTTP E2E' do
|
||||||
expect(unauthenticated_update[:status]).to eq(401)
|
expect(unauthenticated_update[:status]).to eq(401)
|
||||||
|
|
||||||
expect(timer_seconds[:status]).to eq(200)
|
expect(timer_seconds[:status]).to eq(200)
|
||||||
expect(timer_seconds.dig(:json, :timer_end)).to be_a(String)
|
expect(timer_seconds.dig(:json, :timestamp)).to be_a(String)
|
||||||
|
expect(timer_seconds.dig(:json, :timer_mode)).to eq('countdown')
|
||||||
expect(timer_show[:status]).to eq(200)
|
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, :timestamp)).to eq(timer_seconds.dig(:json, :timestamp))
|
||||||
|
expect(timer_show.dig(:json, :timer_mode)).to eq('countdown')
|
||||||
|
expect(timer_countup[:status]).to eq(200)
|
||||||
|
expect(timer_countup.dig(:json, :timestamp)).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, :timestamp)).to be_nil
|
||||||
|
expect(timer_clear.dig(:json, :timer_mode)).to be_nil
|
||||||
|
|
||||||
expect(timer_double[:status]).to eq(422)
|
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')
|
expect(timer_double.dig(:json, :error)).to eq('Only one of timestamp or timestamp_seconds is allowed')
|
||||||
expect(timer_invalid_seconds[:status]).to eq(422)
|
expect(timer_invalid_seconds[:status]).to eq(422)
|
||||||
expect(timer_invalid_seconds.dig(:json, :error)).to eq('Invalid seconds format')
|
expect(timer_invalid_seconds.dig(:json, :error)).to eq('Invalid seconds format')
|
||||||
expect(timer_past[:status]).to eq(422)
|
expect(timer_past[:status]).to eq(422)
|
||||||
expect(timer_past.dig(:json, :error)).to eq('Timer end must be in the future')
|
expect(timer_past.dig(:json, :error)).to eq('Timestamp must be in the future for countdown mode')
|
||||||
|
expect(timer_countup_seconds[:status]).to eq(422)
|
||||||
|
expect(timer_countup_seconds.dig(:json, :error)).to eq('timestamp_seconds is only allowed for countdown mode')
|
||||||
expect(timer_forbidden[:status]).to eq(403)
|
expect(timer_forbidden[:status]).to eq(403)
|
||||||
|
|
||||||
expect([200, 204]).to include(destroy_response[:status])
|
expect([200, 204]).to include(destroy_response[:status])
|
||||||
|
|
|
||||||
|
|
@ -503,7 +503,7 @@ RSpec.describe 'Tournament follower sync HTTP E2E' do
|
||||||
playoff_teams_amount: tournament.fetch(:playoff_teams_amount),
|
playoff_teams_amount: tournament.fetch(:playoff_teams_amount),
|
||||||
instant_finalists_amount: tournament.fetch(:instant_finalists_amount),
|
instant_finalists_amount: tournament.fetch(:instant_finalists_amount),
|
||||||
intermediate_round_participants_amount: tournament.fetch(:intermediate_round_participants_amount),
|
intermediate_round_participants_amount: tournament.fetch(:intermediate_round_participants_amount),
|
||||||
timer_end: tournament[:timer_end],
|
timestamp: tournament[:timestamp],
|
||||||
teams: tournament.fetch(:teams).map { |team| team.slice(:name, :advancing_from_group_stage) }.sort_by { |team| team.fetch(:name) },
|
teams: tournament.fetch(:teams).map { |team| team.slice(:name, :advancing_from_group_stage) }.sort_by { |team| team.fetch(:name) },
|
||||||
team_action_lists: tournament.fetch(:team_action_lists, []).sort_by { |list| list.fetch(:key) }.map do |list|
|
team_action_lists: tournament.fetch(:team_action_lists, []).sort_by { |list| list.fetch(:key) }.map do |list|
|
||||||
{
|
{
|
||||||
|
|
|
||||||
|
|
@ -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('timestamp')).to be_nil
|
||||||
|
expect(initial_payload.fetch('timer_mode')).to be_nil
|
||||||
|
|
||||||
|
countdown = source_client.patch("/tournaments/#{source.fetch(:id)}/timer", body: { timestamp_seconds: 120 })
|
||||||
|
expect(countdown[:status]).to eq(200)
|
||||||
|
|
||||||
|
countdown_payload = wait_for_timer_state!(cable_client, timer_mode: 'countdown')
|
||||||
|
expect(countdown_payload.fetch('timestamp')).to eq(countdown.dig(:json, :timestamp))
|
||||||
|
|
||||||
|
countup = source_client.patch("/tournaments/#{source.fetch(:id)}/timer", 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('timestamp')).to eq(countup.dig(:json, :timestamp))
|
||||||
|
|
||||||
|
clear = source_client.patch("/tournaments/#{source.fetch(:id)}/timer", 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('timestamp')).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('timestamp')).to be_nil
|
||||||
|
expect(initial_payload.fetch('timer_mode')).to be_nil
|
||||||
|
|
||||||
|
countdown = client.patch("/tournaments/#{tournament.fetch(:id)}/timer", body: { timestamp_seconds: 120 })
|
||||||
|
expect(countdown[:status]).to eq(200)
|
||||||
|
|
||||||
|
countdown_payload = wait_for_timer_state!(cable_client, timer_mode: 'countdown')
|
||||||
|
expect(countdown_payload.fetch('timestamp')).to eq(countdown.dig(:json, :timestamp))
|
||||||
|
|
||||||
|
countup = client.patch("/tournaments/#{tournament.fetch(:id)}/timer", 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('timestamp')).to eq(countup.dig(:json, :timestamp))
|
||||||
|
|
||||||
|
clear = client.patch("/tournaments/#{tournament.fetch(:id)}/timer", 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('timestamp')).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
|
follower.reload
|
||||||
expect(follower.sync_source_tournament_id).to eq(source.id)
|
expect(follower.sync_source_tournament_id).to eq(source.id)
|
||||||
expect(follower.name).to eq(source.name)
|
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.teams.count).to eq(snapshot.fetch(:teams).count)
|
||||||
expect(follower.beamers.count).to eq(snapshot.fetch(:beamers).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)
|
expect(follower.team_action_lists.count).to eq(snapshot.fetch(:team_action_lists).count)
|
||||||
|
|
@ -182,7 +183,8 @@ RSpec.describe TournamentSnapshotImporter do
|
||||||
playoff_teams_amount: tournament.playoff_teams_amount,
|
playoff_teams_amount: tournament.playoff_teams_amount,
|
||||||
instant_finalists_amount: tournament.instant_finalists_amount,
|
instant_finalists_amount: tournament.instant_finalists_amount,
|
||||||
intermediate_round_participants_amount: tournament.intermediate_round_participants_amount,
|
intermediate_round_participants_amount: tournament.intermediate_round_participants_amount,
|
||||||
timer_end: tournament.timer_end&.iso8601,
|
timestamp: tournament.timestamp&.iso8601,
|
||||||
|
timer_mode: tournament.timer_mode,
|
||||||
teams: tournament.teams.order(:name).map { |team| team.name },
|
teams: tournament.teams.order(:name).map { |team| team.name },
|
||||||
team_action_lists: tournament.team_action_lists.order(:key).map do |list|
|
team_action_lists: tournament.team_action_lists.order(:key).map do |list|
|
||||||
{
|
{
|
||||||
|
|
|
||||||
3
tasks.py
3
tasks.py
|
|
@ -1154,7 +1154,7 @@ def docker_blackbox_follow_test(
|
||||||
]
|
]
|
||||||
full_command = _docker_compose_cmd(project_name=BLACKBOX_FOLLOW_PROJECT, compose_file=BLACKBOX_FOLLOW_COMPOSE_FILE) + command
|
full_command = _docker_compose_cmd(project_name=BLACKBOX_FOLLOW_PROJECT, compose_file=BLACKBOX_FOLLOW_COMPOSE_FILE) + command
|
||||||
print(_format_command(full_command), flush=True)
|
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:
|
if result.returncode != 0:
|
||||||
raise subprocess.CalledProcessError(
|
raise subprocess.CalledProcessError(
|
||||||
|
|
@ -1725,6 +1725,7 @@ def blackbox_follow_service(
|
||||||
spec_path=[
|
spec_path=[
|
||||||
"spec/e2e/http/tournament_follow_sync_spec.rb",
|
"spec/e2e/http/tournament_follow_sync_spec.rb",
|
||||||
"spec/e2e/http/tournament_follow_sync_websocket_spec.rb",
|
"spec/e2e/http/tournament_follow_sync_websocket_spec.rb",
|
||||||
|
"spec/e2e/http/tournament_timer_follow_sync_websocket_spec.rb",
|
||||||
"spec/e2e/http/tournament_follow_sync_beamers_websocket_spec.rb",
|
"spec/e2e/http/tournament_follow_sync_beamers_websocket_spec.rb",
|
||||||
],
|
],
|
||||||
extra_env={
|
extra_env={
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue