Merge master and fix e2e service env
This commit is contained in:
commit
90e71a068c
|
|
@ -55,6 +55,9 @@ rails e2e spec:
|
||||||
alias: postgres
|
alias: postgres
|
||||||
- name: $CI_REGISTRY_IMAGE/production/commits:$CI_COMMIT_SHA
|
- name: $CI_REGISTRY_IMAGE/production/commits:$CI_COMMIT_SHA
|
||||||
alias: app
|
alias: app
|
||||||
|
variables:
|
||||||
|
GIT_COMMIT_SHA: $CI_COMMIT_SHA
|
||||||
|
TURNIERE_DISABLE_EMAIL_DELIVERY: "1"
|
||||||
command:
|
command:
|
||||||
- bundle
|
- bundle
|
||||||
- exec
|
- exec
|
||||||
|
|
@ -125,6 +128,8 @@ rails follow sync e2e spec:
|
||||||
SECRET_KEY_BASE: turniere-blackbox-secret-key-base-please-change-in-real-prod
|
SECRET_KEY_BASE: turniere-blackbox-secret-key-base-please-change-in-real-prod
|
||||||
MAILGUN_API_KEY: blackbox-test-api-key
|
MAILGUN_API_KEY: blackbox-test-api-key
|
||||||
MAILGUN_DOMAIN: blackbox.example.com
|
MAILGUN_DOMAIN: blackbox.example.com
|
||||||
|
GIT_COMMIT_SHA: $CI_COMMIT_SHA
|
||||||
|
TURNIERE_DISABLE_EMAIL_DELIVERY: "1"
|
||||||
TOURNAMENT_SYNC_HTTP_TIMEOUT_SECONDS: "3"
|
TOURNAMENT_SYNC_HTTP_TIMEOUT_SECONDS: "3"
|
||||||
NO_PROXY: source-app,follower-app,source-postgres,follower-postgres,localhost,127.0.0.1
|
NO_PROXY: source-app,follower-app,source-postgres,follower-postgres,localhost,127.0.0.1
|
||||||
no_proxy: source-app,follower-app,source-postgres,follower-postgres,localhost,127.0.0.1
|
no_proxy: source-app,follower-app,source-postgres,follower-postgres,localhost,127.0.0.1
|
||||||
|
|
@ -148,6 +153,8 @@ rails follow sync e2e spec:
|
||||||
SECRET_KEY_BASE: turniere-blackbox-secret-key-base-please-change-in-real-prod
|
SECRET_KEY_BASE: turniere-blackbox-secret-key-base-please-change-in-real-prod
|
||||||
MAILGUN_API_KEY: blackbox-test-api-key
|
MAILGUN_API_KEY: blackbox-test-api-key
|
||||||
MAILGUN_DOMAIN: blackbox.example.com
|
MAILGUN_DOMAIN: blackbox.example.com
|
||||||
|
GIT_COMMIT_SHA: $CI_COMMIT_SHA
|
||||||
|
TURNIERE_DISABLE_EMAIL_DELIVERY: "1"
|
||||||
TOURNAMENT_SYNC_HTTP_TIMEOUT_SECONDS: "3"
|
TOURNAMENT_SYNC_HTTP_TIMEOUT_SECONDS: "3"
|
||||||
NO_PROXY: source-app,follower-app,source-postgres,follower-postgres,localhost,127.0.0.1
|
NO_PROXY: source-app,follower-app,source-postgres,follower-postgres,localhost,127.0.0.1
|
||||||
no_proxy: source-app,follower-app,source-postgres,follower-postgres,localhost,127.0.0.1
|
no_proxy: source-app,follower-app,source-postgres,follower-postgres,localhost,127.0.0.1
|
||||||
|
|
|
||||||
|
|
@ -171,14 +171,18 @@ class TournamentsController < ApplicationController
|
||||||
def timer_params
|
def timer_params
|
||||||
{
|
{
|
||||||
timestamp: params[:timestamp],
|
timestamp: params[:timestamp],
|
||||||
timer_mode: params[:timer_mode]
|
timer_mode: params[:timer_mode],
|
||||||
|
timer_reason: params[:timer_reason],
|
||||||
|
timer_reason_text: params[:timer_reason_text]
|
||||||
}
|
}
|
||||||
end
|
end
|
||||||
|
|
||||||
def serialize_timer_state(tournament)
|
def serialize_timer_state(tournament)
|
||||||
{
|
{
|
||||||
timestamp: tournament.timestamp&.iso8601,
|
timestamp: tournament.timestamp&.iso8601,
|
||||||
timer_mode: tournament.timer_mode
|
timer_mode: tournament.timer_mode,
|
||||||
|
timer_reason: tournament.timer_reason,
|
||||||
|
timer_reason_text: tournament.timer_reason_text
|
||||||
}
|
}
|
||||||
end
|
end
|
||||||
|
|
||||||
|
|
@ -333,40 +337,41 @@ class TournamentsController < ApplicationController
|
||||||
timestamp = params[:timestamp]
|
timestamp = params[:timestamp]
|
||||||
timestamp_seconds = params[:timestamp_seconds]
|
timestamp_seconds = params[:timestamp_seconds]
|
||||||
timer_mode = params[:timer_mode]
|
timer_mode = params[:timer_mode]
|
||||||
|
normalize_timer_reason_params
|
||||||
|
timer_reason = params[:timer_reason]
|
||||||
|
timer_reason_text = params[:timer_reason_text]
|
||||||
clear_timer = ActiveModel::Type::Boolean.new.cast(params[:clear_timer])
|
clear_timer = ActiveModel::Type::Boolean.new.cast(params[:clear_timer])
|
||||||
|
|
||||||
if clear_timer
|
if clear_timer
|
||||||
if timestamp.present? || timestamp_seconds.present? || timer_mode.present?
|
return validate_clear_timer_params(timestamp, timestamp_seconds, timer_mode, timer_reason, timer_reason_text)
|
||||||
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_mode.present? && !%w[countdown countup].include?(timer_mode)
|
if timer_mode.present? && !Tournament::TIMER_MODES.include?(timer_mode)
|
||||||
return render json: { error: 'Invalid timer_mode' }, status: :unprocessable_entity
|
return render_timer_validation_error('Invalid timer_mode')
|
||||||
end
|
end
|
||||||
|
|
||||||
|
timer_reason_error = validate_timer_reason_params(timer_reason, timer_reason_text)
|
||||||
|
return render_timer_validation_error(timer_reason_error) if timer_reason_error
|
||||||
|
|
||||||
# throw error if both timestamp and timestamp_seconds are present
|
# throw error if both timestamp and timestamp_seconds are present
|
||||||
if timestamp.present? && timestamp_seconds.present?
|
if timestamp.present? && timestamp_seconds.present?
|
||||||
return render json: { error: 'Only one of timestamp or timestamp_seconds is allowed' }, status: :unprocessable_entity
|
return render_timer_validation_error('Only one of timestamp or timestamp_seconds is allowed')
|
||||||
end
|
end
|
||||||
|
|
||||||
if timer_mode == 'countup' && timestamp_seconds.present?
|
if timer_mode == 'countup' && timestamp_seconds.present?
|
||||||
return render json: { error: 'timestamp_seconds is only allowed for countdown mode' }, status: :unprocessable_entity
|
return render_timer_validation_error('timestamp_seconds is only allowed for countdown mode')
|
||||||
end
|
end
|
||||||
|
|
||||||
if timestamp_seconds.present?
|
if timestamp_seconds.present?
|
||||||
begin
|
begin
|
||||||
timestamp_seconds = Integer(timestamp_seconds)
|
timestamp_seconds = Integer(timestamp_seconds)
|
||||||
rescue ArgumentError
|
rescue ArgumentError
|
||||||
return render json: { error: 'Invalid seconds format' }, status: :unprocessable_entity
|
return render_timer_validation_error('Invalid seconds format')
|
||||||
end
|
end
|
||||||
|
|
||||||
return render json: { error: 'Timestamp must be in the future for countdown mode' }, status: :unprocessable_entity if timestamp_seconds <= 0
|
if timestamp_seconds <= 0
|
||||||
|
return render_timer_validation_error('Timestamp must be in the future for countdown mode')
|
||||||
|
end
|
||||||
|
|
||||||
parsed_time = Time.zone.now + timestamp_seconds
|
parsed_time = Time.zone.now + timestamp_seconds
|
||||||
params[:timestamp] = parsed_time
|
params[:timestamp] = parsed_time
|
||||||
|
|
@ -375,24 +380,63 @@ class TournamentsController < ApplicationController
|
||||||
begin
|
begin
|
||||||
parsed_time = Time.zone.parse(timestamp)
|
parsed_time = Time.zone.parse(timestamp)
|
||||||
if parsed_time.nil?
|
if parsed_time.nil?
|
||||||
return render json: { error: 'Invalid timestamp format' }, status: :unprocessable_entity
|
return render_timer_validation_error('Invalid timestamp format')
|
||||||
elsif timer_mode != 'countup' && !parsed_time.future?
|
elsif timer_mode != 'countup' && !parsed_time.future?
|
||||||
return render json: { error: 'Timestamp must be in the future for countdown mode' }, status: :unprocessable_entity
|
return render_timer_validation_error('Timestamp must be in the future for countdown mode')
|
||||||
end
|
end
|
||||||
|
|
||||||
params[:timestamp] = parsed_time
|
params[:timestamp] = parsed_time
|
||||||
params[:timer_mode] = timer_mode.presence || 'countdown'
|
params[:timer_mode] = timer_mode.presence || 'countdown'
|
||||||
rescue ArgumentError
|
rescue ArgumentError
|
||||||
return render json: { error: 'Invalid timestamp format' }, status: :unprocessable_entity
|
return render_timer_validation_error('Invalid timestamp format')
|
||||||
end
|
end
|
||||||
elsif timer_mode == 'countup'
|
elsif timer_mode == 'countup'
|
||||||
params[:timestamp] = Time.zone.now
|
params[:timestamp] = Time.zone.now
|
||||||
elsif timer_mode.present?
|
elsif timer_mode.present?
|
||||||
return render json: { error: 'timer_mode requires timestamp, timestamp_seconds, or clear_timer' },
|
return render_timer_validation_error('timer_mode requires timestamp, timestamp_seconds, or clear_timer')
|
||||||
status: :unprocessable_entity
|
|
||||||
else
|
else
|
||||||
return render json: { error: 'timestamp, timestamp_seconds, or clear_timer is required' },
|
return render_timer_validation_error('timestamp, timestamp_seconds, or clear_timer is required')
|
||||||
status: :unprocessable_entity
|
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
|
def normalize_timer_reason_params
|
||||||
|
params[:timer_reason] = params[:timer_reason].presence
|
||||||
|
params[:timer_reason_text] = timer_reason_text_param
|
||||||
|
end
|
||||||
|
|
||||||
|
def timer_reason_text_param
|
||||||
|
return nil unless params.key?(:timer_reason_text)
|
||||||
|
|
||||||
|
params[:timer_reason_text].to_s.strip.presence
|
||||||
|
end
|
||||||
|
|
||||||
|
def validate_clear_timer_params(timestamp, timestamp_seconds, timer_mode, timer_reason, timer_reason_text)
|
||||||
|
if timestamp.present? || timestamp_seconds.present? || timer_mode.present? ||
|
||||||
|
timer_reason.present? || timer_reason_text.present?
|
||||||
|
return render_timer_validation_error('clear_timer cannot be combined with timer values or timer reason')
|
||||||
|
end
|
||||||
|
|
||||||
|
params[:timestamp] = nil
|
||||||
|
params[:timer_mode] = nil
|
||||||
|
params[:timer_reason] = nil
|
||||||
|
params[:timer_reason_text] = nil
|
||||||
|
end
|
||||||
|
|
||||||
|
def validate_timer_reason_params(timer_reason, timer_reason_text)
|
||||||
|
return if timer_reason.blank? && timer_reason_text.blank?
|
||||||
|
return 'timer_reason_text is only allowed when timer_reason is custom' if timer_reason.blank?
|
||||||
|
return 'Invalid timer_reason' unless Tournament::TIMER_REASONS.include?(timer_reason)
|
||||||
|
|
||||||
|
if timer_reason == 'custom' && timer_reason_text.blank?
|
||||||
|
return 'timer_reason_text is required for custom timer_reason'
|
||||||
|
end
|
||||||
|
|
||||||
|
if timer_reason != 'custom' && timer_reason_text.present?
|
||||||
|
'timer_reason_text is only allowed when timer_reason is custom'
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
def render_timer_validation_error(message)
|
||||||
|
render json: { error: message }, status: :unprocessable_entity
|
||||||
|
end
|
||||||
end
|
end
|
||||||
|
|
|
||||||
|
|
@ -3,6 +3,9 @@
|
||||||
require 'securerandom'
|
require 'securerandom'
|
||||||
|
|
||||||
class Tournament < ApplicationRecord
|
class Tournament < ApplicationRecord
|
||||||
|
TIMER_MODES = %w[countdown countup].freeze
|
||||||
|
TIMER_REASONS = %w[tournament_start playoff_start match_end custom].freeze
|
||||||
|
|
||||||
belongs_to :user
|
belongs_to :user
|
||||||
belongs_to :owner, class_name: 'User', foreign_key: :user_id
|
belongs_to :owner, class_name: 'User', foreign_key: :user_id
|
||||||
has_many :teams, dependent: :destroy
|
has_many :teams, dependent: :destroy
|
||||||
|
|
@ -12,14 +15,17 @@ 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 :timer_mode, inclusion: { in: TIMER_MODES }, allow_nil: true
|
||||||
|
validates :timer_reason, inclusion: { in: TIMER_REASONS }, 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
|
||||||
|
|
||||||
validate :playoff_teams_amount_is_positive_power_of_two
|
validate :playoff_teams_amount_is_positive_power_of_two
|
||||||
|
validate :timer_reason_text_matches_reason
|
||||||
|
|
||||||
after_initialize :generate_code
|
after_initialize :generate_code
|
||||||
after_create_commit :ensure_default_beamer!
|
after_create_commit :ensure_default_beamer!
|
||||||
|
before_validation :normalize_timer_reason
|
||||||
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?
|
after_commit :broadcast_timer_state_change, if: :saved_change_to_timer_state?
|
||||||
|
|
||||||
|
|
@ -99,8 +105,24 @@ class Tournament < ApplicationRecord
|
||||||
self.sync_auth_token = nil
|
self.sync_auth_token = nil
|
||||||
end
|
end
|
||||||
|
|
||||||
|
def normalize_timer_reason
|
||||||
|
self.timer_reason = timer_reason.presence
|
||||||
|
self.timer_reason_text = timer_reason_text&.strip&.presence
|
||||||
|
end
|
||||||
|
|
||||||
|
def timer_reason_text_matches_reason
|
||||||
|
if timer_reason == 'custom'
|
||||||
|
errors.add(:timer_reason_text, "can't be blank") if timer_reason_text.blank?
|
||||||
|
elsif timer_reason_text.present?
|
||||||
|
errors.add(:timer_reason_text, 'is only allowed when timer_reason is custom')
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
def saved_change_to_timer_state?
|
def saved_change_to_timer_state?
|
||||||
saved_change_to_timestamp? || saved_change_to_timer_mode?
|
saved_change_to_timestamp? ||
|
||||||
|
saved_change_to_timer_mode? ||
|
||||||
|
saved_change_to_timer_reason? ||
|
||||||
|
saved_change_to_timer_reason_text?
|
||||||
end
|
end
|
||||||
|
|
||||||
def broadcast_timer_state_change
|
def broadcast_timer_state_change
|
||||||
|
|
|
||||||
|
|
@ -16,6 +16,8 @@ class TournamentSerializer < SimpleTournamentSerializer
|
||||||
end
|
end
|
||||||
|
|
||||||
attribute :timer_mode
|
attribute :timer_mode
|
||||||
|
attribute :timer_reason
|
||||||
|
attribute :timer_reason_text
|
||||||
|
|
||||||
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
|
||||||
|
|
|
||||||
|
|
@ -24,6 +24,8 @@ class TournamentSnapshotBuilder
|
||||||
payload = tournament.attributes.symbolize_keys.slice(*ROOT_ATTRIBUTES)
|
payload = tournament.attributes.symbolize_keys.slice(*ROOT_ATTRIBUTES)
|
||||||
payload[:timestamp] = tournament.timestamp&.iso8601
|
payload[:timestamp] = tournament.timestamp&.iso8601
|
||||||
payload[:timer_mode] = tournament.timer_mode
|
payload[:timer_mode] = tournament.timer_mode
|
||||||
|
payload[:timer_reason] = tournament.timer_reason
|
||||||
|
payload[:timer_reason_text] = tournament.timer_reason_text
|
||||||
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
|
||||||
|
|
|
||||||
|
|
@ -69,6 +69,8 @@ class TournamentSnapshotImporter
|
||||||
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[:timestamp] = parse_time(snapshot[:timestamp])
|
root_attrs[:timestamp] = parse_time(snapshot[:timestamp])
|
||||||
root_attrs[:timer_mode] = snapshot[:timer_mode]
|
root_attrs[:timer_mode] = snapshot[:timer_mode]
|
||||||
|
root_attrs[:timer_reason] = snapshot[:timer_reason]
|
||||||
|
root_attrs[:timer_reason_text] = snapshot[:timer_reason_text]
|
||||||
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)
|
||||||
|
|
|
||||||
|
|
@ -14,6 +14,8 @@ class TournamentSyncSchema
|
||||||
intermediate_round_participants_amount
|
intermediate_round_participants_amount
|
||||||
timestamp
|
timestamp
|
||||||
timer_mode
|
timer_mode
|
||||||
|
timer_reason
|
||||||
|
timer_reason_text
|
||||||
].freeze,
|
].freeze,
|
||||||
ignored: %w[
|
ignored: %w[
|
||||||
user_id
|
user_id
|
||||||
|
|
|
||||||
|
|
@ -7,7 +7,9 @@ class TournamentTimerBroadcastPayload
|
||||||
type: 'timer.updated',
|
type: 'timer.updated',
|
||||||
tournament_id: tournament.id,
|
tournament_id: tournament.id,
|
||||||
timestamp: tournament.timestamp&.iso8601,
|
timestamp: tournament.timestamp&.iso8601,
|
||||||
timer_mode: tournament.timer_mode
|
timer_mode: tournament.timer_mode,
|
||||||
|
timer_reason: tournament.timer_reason,
|
||||||
|
timer_reason_text: tournament.timer_reason_text
|
||||||
}
|
}
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,6 @@
|
||||||
|
class AddTimerReasonToTournaments < ActiveRecord::Migration[7.0]
|
||||||
|
def change
|
||||||
|
add_column :tournaments, :timer_reason, :string
|
||||||
|
add_column :tournaments, :timer_reason_text, :text
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
@ -10,7 +10,7 @@
|
||||||
#
|
#
|
||||||
# It's strongly recommended that you check this file into your version control system.
|
# It's strongly recommended that you check this file into your version control system.
|
||||||
|
|
||||||
ActiveRecord::Schema[8.1].define(version: 2026_04_26_121000) do
|
ActiveRecord::Schema[8.1].define(version: 2026_04_28_120000) do
|
||||||
create_table "beamers", force: :cascade do |t|
|
create_table "beamers", force: :cascade do |t|
|
||||||
t.json "config", default: {}, null: false
|
t.json "config", default: {}, null: false
|
||||||
t.datetime "created_at", null: false
|
t.datetime "created_at", null: false
|
||||||
|
|
@ -165,6 +165,8 @@ ActiveRecord::Schema[8.1].define(version: 2026_04_26_121000) do
|
||||||
t.string "sync_target_url"
|
t.string "sync_target_url"
|
||||||
t.string "timer_mode"
|
t.string "timer_mode"
|
||||||
t.datetime "timestamp"
|
t.datetime "timestamp"
|
||||||
|
t.string "timer_reason"
|
||||||
|
t.text "timer_reason_text"
|
||||||
t.datetime "updated_at", precision: nil, null: false
|
t.datetime "updated_at", precision: nil, null: false
|
||||||
t.integer "user_id", null: false
|
t.integer "user_id", null: false
|
||||||
t.index ["code"], name: "index_tournaments_on_code", unique: true
|
t.index ["code"], name: "index_tournaments_on_code", unique: true
|
||||||
|
|
|
||||||
|
|
@ -15,6 +15,8 @@ Frontend should treat timer state as:
|
||||||
- no timer: `timestamp = null`, `timer_mode = null`
|
- no timer: `timestamp = null`, `timer_mode = null`
|
||||||
- countdown: `timer_mode = "countdown"`
|
- countdown: `timer_mode = "countdown"`
|
||||||
- countup: `timer_mode = "countup"`
|
- countup: `timer_mode = "countup"`
|
||||||
|
- optional reason: `timer_reason` may be `tournament_start`, `playoff_start`, `match_end`, or `custom`
|
||||||
|
- custom reason: `timer_reason = "custom"` and `timer_reason_text` contains the display text
|
||||||
|
|
||||||
## Read Flow
|
## Read Flow
|
||||||
|
|
||||||
|
|
@ -31,7 +33,9 @@ Response shape:
|
||||||
```json
|
```json
|
||||||
{
|
{
|
||||||
"timestamp": "2026-04-24T10:15:00Z",
|
"timestamp": "2026-04-24T10:15:00Z",
|
||||||
"timer_mode": "countdown"
|
"timer_mode": "countdown",
|
||||||
|
"timer_reason": "tournament_start",
|
||||||
|
"timer_reason_text": null
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
|
|
@ -40,6 +44,13 @@ Possible values:
|
||||||
- `timer_mode = "countdown"`
|
- `timer_mode = "countdown"`
|
||||||
- `timer_mode = "countup"`
|
- `timer_mode = "countup"`
|
||||||
- `timer_mode = null`
|
- `timer_mode = null`
|
||||||
|
- `timer_reason = "tournament_start"`
|
||||||
|
- `timer_reason = "playoff_start"`
|
||||||
|
- `timer_reason = "match_end"`
|
||||||
|
- `timer_reason = "custom"`
|
||||||
|
- `timer_reason = null`
|
||||||
|
|
||||||
|
`timer_reason_text` is only used when `timer_reason = "custom"`.
|
||||||
|
|
||||||
## Meaning Of `timestamp`
|
## Meaning Of `timestamp`
|
||||||
|
|
||||||
|
|
@ -103,7 +114,8 @@ Request:
|
||||||
|
|
||||||
```json
|
```json
|
||||||
{
|
{
|
||||||
"timestamp_seconds": 120
|
"timestamp_seconds": 120,
|
||||||
|
"timer_reason": "tournament_start"
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
|
|
@ -120,7 +132,8 @@ Request:
|
||||||
|
|
||||||
```json
|
```json
|
||||||
{
|
{
|
||||||
"timestamp": "2026-04-24T10:15:00Z"
|
"timestamp": "2026-04-24T10:15:00Z",
|
||||||
|
"timer_reason": "playoff_start"
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
|
|
@ -129,7 +142,9 @@ Optional explicit mode:
|
||||||
```json
|
```json
|
||||||
{
|
{
|
||||||
"timestamp": "2026-04-24T10:15:00Z",
|
"timestamp": "2026-04-24T10:15:00Z",
|
||||||
"timer_mode": "countdown"
|
"timer_mode": "countdown",
|
||||||
|
"timer_reason": "custom",
|
||||||
|
"timer_reason_text": "Opening ceremony ran long"
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
|
|
@ -139,7 +154,8 @@ Request:
|
||||||
|
|
||||||
```json
|
```json
|
||||||
{
|
{
|
||||||
"timer_mode": "countup"
|
"timer_mode": "countup",
|
||||||
|
"timer_reason": "match_end"
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
|
|
@ -157,7 +173,9 @@ Backend also accepts explicit timestamp with countup mode:
|
||||||
```json
|
```json
|
||||||
{
|
{
|
||||||
"timestamp": "2026-04-24T10:00:00Z",
|
"timestamp": "2026-04-24T10:00:00Z",
|
||||||
"timer_mode": "countup"
|
"timer_mode": "countup",
|
||||||
|
"timer_reason": "custom",
|
||||||
|
"timer_reason_text": "Manual overtime"
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
|
|
@ -179,10 +197,12 @@ Request:
|
||||||
|
|
||||||
Behavior:
|
Behavior:
|
||||||
|
|
||||||
- backend clears both fields
|
- backend clears all timer fields
|
||||||
- response returns:
|
- response returns:
|
||||||
- `timestamp = null`
|
- `timestamp = null`
|
||||||
- `timer_mode = null`
|
- `timer_mode = null`
|
||||||
|
- `timer_reason = null`
|
||||||
|
- `timer_reason_text = null`
|
||||||
|
|
||||||
## Validation Rules
|
## Validation Rules
|
||||||
|
|
||||||
|
|
@ -194,6 +214,9 @@ Backend rejects:
|
||||||
- `timestamp_seconds` together with `timer_mode = "countup"`
|
- `timestamp_seconds` together with `timer_mode = "countup"`
|
||||||
- `timer_mode` by itself unless it is `countup`
|
- `timer_mode` by itself unless it is `countup`
|
||||||
- `clear_timer` mixed with timer values
|
- `clear_timer` mixed with timer values
|
||||||
|
- invalid `timer_reason`
|
||||||
|
- `timer_reason = "custom"` without `timer_reason_text`
|
||||||
|
- `timer_reason_text` with any non-custom `timer_reason`
|
||||||
|
|
||||||
Countdown timestamps must be future timestamps.
|
Countdown timestamps must be future timestamps.
|
||||||
Countup timestamps may be current or past timestamps.
|
Countup timestamps may be current or past timestamps.
|
||||||
|
|
@ -223,7 +246,9 @@ Payload:
|
||||||
"type": "timer.updated",
|
"type": "timer.updated",
|
||||||
"tournament_id": 123,
|
"tournament_id": 123,
|
||||||
"timestamp": "2026-04-24T10:15:00Z",
|
"timestamp": "2026-04-24T10:15:00Z",
|
||||||
"timer_mode": "countdown"
|
"timer_mode": "countdown",
|
||||||
|
"timer_reason": "tournament_start",
|
||||||
|
"timer_reason_text": null
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
|
|
@ -243,7 +268,7 @@ Frontend rule:
|
||||||
Recommended flow:
|
Recommended flow:
|
||||||
|
|
||||||
1. Load tournament via `GET /tournaments/:id`
|
1. Load tournament via `GET /tournaments/:id`
|
||||||
2. Read `timer_mode` and `timestamp`
|
2. Read `timer_mode`, `timestamp`, `timer_reason`, and `timer_reason_text`
|
||||||
3. Open ActionCable subscription for same tournament id
|
3. Open ActionCable subscription for same tournament id
|
||||||
4. Replace local timer state whenever `timer.updated` arrives
|
4. Replace local timer state whenever `timer.updated` arrives
|
||||||
5. Derive displayed time from `Date.now()` plus stored backend timestamp
|
5. Derive displayed time from `Date.now()` plus stored backend timestamp
|
||||||
|
|
@ -252,9 +277,11 @@ Recommended client state:
|
||||||
|
|
||||||
```ts
|
```ts
|
||||||
type TimerState =
|
type TimerState =
|
||||||
| { mode: null; timestampIso: null }
|
| { mode: null; timestampIso: null; reason: null; reasonText: null }
|
||||||
| { mode: "countdown"; timestampIso: string }
|
| { mode: "countdown"; timestampIso: string; reason: TimerReason | null; reasonText: string | null }
|
||||||
| { mode: "countup"; timestampIso: string };
|
| { mode: "countup"; timestampIso: string; reason: TimerReason | null; reasonText: string | null };
|
||||||
|
|
||||||
|
type TimerReason = "tournament_start" | "playoff_start" | "match_end" | "custom";
|
||||||
```
|
```
|
||||||
|
|
||||||
Suggested derivation logic:
|
Suggested derivation logic:
|
||||||
|
|
@ -283,7 +310,7 @@ Recommended rendering:
|
||||||
|
|
||||||
Recommended local ticking:
|
Recommended local ticking:
|
||||||
|
|
||||||
- keep backend state as timestamp + mode
|
- keep backend state as timestamp + mode + reason
|
||||||
- update rendered value with local interval
|
- update rendered value with local interval
|
||||||
- do not poll backend every second
|
- do not poll backend every second
|
||||||
|
|
||||||
|
|
@ -300,7 +327,7 @@ Timer state is included in tournament sync snapshots.
|
||||||
That means:
|
That means:
|
||||||
|
|
||||||
- leader timer changes propagate to follower
|
- leader timer changes propagate to follower
|
||||||
- follower tournament exposes same `timestamp` / `timer_mode`
|
- follower tournament exposes same `timestamp` / `timer_mode` / `timer_reason` / `timer_reason_text`
|
||||||
- follower websocket subscribers receive `timer.updated`
|
- follower websocket subscribers receive `timer.updated`
|
||||||
- follower still rejects timer writes while read only
|
- follower still rejects timer writes while read only
|
||||||
|
|
||||||
|
|
@ -321,5 +348,5 @@ Implemented coverage:
|
||||||
|
|
||||||
Current note:
|
Current note:
|
||||||
|
|
||||||
- single-backend HTTP E2E timer specs pass
|
- single-backend and local dual-server 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
|
- follower blackbox task still has existing Docker/network issue in follow setup, so production-style follow stack verification is not green yet
|
||||||
|
|
|
||||||
|
|
@ -70,6 +70,8 @@ Payload:
|
||||||
"intermediate_round_participants_amount": 0,
|
"intermediate_round_participants_amount": 0,
|
||||||
"timestamp": null,
|
"timestamp": null,
|
||||||
"timer_mode": null,
|
"timer_mode": null,
|
||||||
|
"timer_reason": null,
|
||||||
|
"timer_reason_text": null,
|
||||||
"owner_username": "owner",
|
"owner_username": "owner",
|
||||||
"stages": [],
|
"stages": [],
|
||||||
"teams": [],
|
"teams": [],
|
||||||
|
|
|
||||||
|
|
@ -30,9 +30,12 @@ module TurniereE2E
|
||||||
|
|
||||||
timer_response = patch!(
|
timer_response = patch!(
|
||||||
"/tournaments/#{tournament.fetch(:id)}/timer",
|
"/tournaments/#{tournament.fetch(:id)}/timer",
|
||||||
body: { timestamp_seconds: 600 }
|
body: { timestamp_seconds: 600, timer_reason: 'tournament_start' }
|
||||||
)
|
)
|
||||||
checkpoints[:timer_set] = { timestamp: timer_response.fetch(:timestamp) }
|
checkpoints[:timer_set] = {
|
||||||
|
timestamp: timer_response.fetch(:timestamp),
|
||||||
|
timer_reason: timer_response.fetch(:timer_reason)
|
||||||
|
}
|
||||||
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
|
||||||
timestamp timer_mode owner_username stages teams team_action_lists
|
timestamp timer_mode timer_reason timer_reason_text 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)
|
||||||
|
|
@ -224,13 +224,20 @@ RSpec.describe TournamentsController, type: :controller do
|
||||||
describe 'GET #timer' do
|
describe 'GET #timer' do
|
||||||
it 'returns timer state in ISO 8601 format' do
|
it 'returns timer state in ISO 8601 format' do
|
||||||
timestamp = Time.utc(2026, 4, 13, 18, 0, 0)
|
timestamp = Time.utc(2026, 4, 13, 18, 0, 0)
|
||||||
@tournament.update!(timestamp: timestamp, timer_mode: 'countdown')
|
@tournament.update!(
|
||||||
|
timestamp: timestamp,
|
||||||
|
timer_mode: 'countdown',
|
||||||
|
timer_reason: 'custom',
|
||||||
|
timer_reason_text: 'Opening ceremony'
|
||||||
|
)
|
||||||
|
|
||||||
get :timer, 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)[:timestamp]).to eq(timestamp.iso8601)
|
expect(deserialize_response(response)[:timestamp]).to eq(timestamp.iso8601)
|
||||||
expect(deserialize_response(response)[:timer_mode]).to eq('countdown')
|
expect(deserialize_response(response)[:timer_mode]).to eq('countdown')
|
||||||
|
expect(deserialize_response(response)[:timer_reason]).to eq('custom')
|
||||||
|
expect(deserialize_response(response)[:timer_reason_text]).to eq('Opening ceremony')
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
|
|
@ -242,35 +249,60 @@ RSpec.describe TournamentsController, type: :controller do
|
||||||
it 'stores timezone-aware timestamp values without shifting them' do
|
it 'stores timezone-aware timestamp values without shifting them' do
|
||||||
timestamp = '2030-04-13T18:00:00+02:00'
|
timestamp = '2030-04-13T18:00:00+02:00'
|
||||||
|
|
||||||
patch :set_timer, params: { id: @tournament.to_param, timestamp: timestamp }
|
patch :set_timer, params: {
|
||||||
|
id: @tournament.to_param,
|
||||||
|
timestamp: timestamp,
|
||||||
|
timer_reason: 'tournament_start'
|
||||||
|
}
|
||||||
|
|
||||||
expect(response).to be_successful
|
expect(response).to be_successful
|
||||||
expect(@tournament.reload.timestamp.iso8601).to eq(Time.iso8601(timestamp).utc.iso8601)
|
expect(@tournament.reload.timestamp.iso8601).to eq(Time.iso8601(timestamp).utc.iso8601)
|
||||||
|
expect(@tournament.timer_reason).to eq('tournament_start')
|
||||||
|
expect(@tournament.timer_reason_text).to be_nil
|
||||||
expect(deserialize_response(response)[:timestamp]).to eq(@tournament.timestamp.iso8601)
|
expect(deserialize_response(response)[:timestamp]).to eq(@tournament.timestamp.iso8601)
|
||||||
expect(deserialize_response(response)[:timer_mode]).to eq('countdown')
|
expect(deserialize_response(response)[:timer_mode]).to eq('countdown')
|
||||||
|
expect(deserialize_response(response)[:timer_reason]).to eq('tournament_start')
|
||||||
end
|
end
|
||||||
|
|
||||||
it 'starts countup timer at current time when timer_mode=countup' do
|
it 'starts countup timer at current time when timer_mode=countup' do
|
||||||
before_request = Time.zone.now
|
before_request = Time.zone.now
|
||||||
|
|
||||||
patch :set_timer, params: { id: @tournament.to_param, timer_mode: 'countup' }
|
patch :set_timer, params: {
|
||||||
|
id: @tournament.to_param,
|
||||||
|
timer_mode: 'countup',
|
||||||
|
timer_reason: 'custom',
|
||||||
|
timer_reason_text: 'Tie-break delay'
|
||||||
|
}
|
||||||
|
|
||||||
expect(response).to be_successful
|
expect(response).to be_successful
|
||||||
expect(@tournament.reload.timer_mode).to eq('countup')
|
expect(@tournament.reload.timer_mode).to eq('countup')
|
||||||
|
expect(@tournament.timer_reason).to eq('custom')
|
||||||
|
expect(@tournament.timer_reason_text).to eq('Tie-break delay')
|
||||||
expect(@tournament.timestamp).to be_between(before_request - 1.second, Time.zone.now + 1.second)
|
expect(@tournament.timestamp).to be_between(before_request - 1.second, Time.zone.now + 1.second)
|
||||||
expect(deserialize_response(response)[:timer_mode]).to eq('countup')
|
expect(deserialize_response(response)[:timer_mode]).to eq('countup')
|
||||||
|
expect(deserialize_response(response)[:timer_reason]).to eq('custom')
|
||||||
|
expect(deserialize_response(response)[:timer_reason_text]).to eq('Tie-break delay')
|
||||||
end
|
end
|
||||||
|
|
||||||
it 'clears timer state when clear_timer=true' do
|
it 'clears timer state when clear_timer=true' do
|
||||||
@tournament.update!(timestamp: Time.utc(2030, 4, 13, 18, 0, 0), timer_mode: 'countdown')
|
@tournament.update!(
|
||||||
|
timestamp: Time.utc(2030, 4, 13, 18, 0, 0),
|
||||||
|
timer_mode: 'countdown',
|
||||||
|
timer_reason: 'custom',
|
||||||
|
timer_reason_text: 'Warm-up pause'
|
||||||
|
)
|
||||||
|
|
||||||
patch :set_timer, params: { id: @tournament.to_param, clear_timer: true }
|
patch :set_timer, params: { id: @tournament.to_param, clear_timer: true }
|
||||||
|
|
||||||
expect(response).to be_successful
|
expect(response).to be_successful
|
||||||
expect(@tournament.reload.timestamp).to be_nil
|
expect(@tournament.reload.timestamp).to be_nil
|
||||||
expect(@tournament.timer_mode).to be_nil
|
expect(@tournament.timer_mode).to be_nil
|
||||||
|
expect(@tournament.timer_reason).to be_nil
|
||||||
|
expect(@tournament.timer_reason_text).to be_nil
|
||||||
expect(deserialize_response(response)[:timestamp]).to be_nil
|
expect(deserialize_response(response)[:timestamp]).to be_nil
|
||||||
expect(deserialize_response(response)[:timer_mode]).to be_nil
|
expect(deserialize_response(response)[:timer_mode]).to be_nil
|
||||||
|
expect(deserialize_response(response)[:timer_reason]).to be_nil
|
||||||
|
expect(deserialize_response(response)[:timer_reason_text]).to be_nil
|
||||||
end
|
end
|
||||||
|
|
||||||
it 'rejects timestamp_seconds for countup mode' do
|
it 'rejects timestamp_seconds for countup mode' do
|
||||||
|
|
@ -279,6 +311,34 @@ RSpec.describe TournamentsController, type: :controller do
|
||||||
expect(response).to have_http_status(:unprocessable_entity)
|
expect(response).to have_http_status(:unprocessable_entity)
|
||||||
expect(deserialize_response(response)[:error]).to eq('timestamp_seconds is only allowed for countdown mode')
|
expect(deserialize_response(response)[:error]).to eq('timestamp_seconds is only allowed for countdown mode')
|
||||||
end
|
end
|
||||||
|
|
||||||
|
it 'rejects invalid timer reason values' do
|
||||||
|
patch :set_timer, params: { id: @tournament.to_param, timestamp_seconds: 120, timer_reason: 'ceremony' }
|
||||||
|
|
||||||
|
expect(response).to have_http_status(:unprocessable_entity)
|
||||||
|
expect(deserialize_response(response)[:error]).to eq('Invalid timer_reason')
|
||||||
|
end
|
||||||
|
|
||||||
|
it 'requires timer_reason_text for custom timer reason' do
|
||||||
|
patch :set_timer, params: { id: @tournament.to_param, timestamp_seconds: 120, timer_reason: 'custom' }
|
||||||
|
|
||||||
|
expect(response).to have_http_status(:unprocessable_entity)
|
||||||
|
expect(deserialize_response(response)[:error]).to eq('timer_reason_text is required for custom timer_reason')
|
||||||
|
end
|
||||||
|
|
||||||
|
it 'rejects timer_reason_text for predefined timer reasons' do
|
||||||
|
patch :set_timer, params: {
|
||||||
|
id: @tournament.to_param,
|
||||||
|
timestamp_seconds: 120,
|
||||||
|
timer_reason: 'match_end',
|
||||||
|
timer_reason_text: 'Extra info'
|
||||||
|
}
|
||||||
|
|
||||||
|
expect(response).to have_http_status(:unprocessable_entity)
|
||||||
|
expect(deserialize_response(response)[:error]).to eq(
|
||||||
|
'timer_reason_text is only allowed when timer_reason is custom'
|
||||||
|
)
|
||||||
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
describe 'POST #create' do
|
describe 'POST #create' do
|
||||||
|
|
|
||||||
|
|
@ -134,15 +134,32 @@ 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)}/timer", body: { timestamp_seconds: 120 })
|
timer_seconds = owner_client.patch("/tournaments/#{tournament.fetch(:id)}/timer", body: {
|
||||||
|
timestamp_seconds: 120,
|
||||||
|
timer_reason: 'tournament_start'
|
||||||
|
})
|
||||||
timer_show = anonymous_client.get("/tournaments/#{tournament.fetch(:id)}/timer")
|
timer_show = anonymous_client.get("/tournaments/#{tournament.fetch(:id)}/timer")
|
||||||
timer_countup = owner_client.patch("/tournaments/#{tournament.fetch(:id)}/timer", body: { timer_mode: 'countup' })
|
timer_countup = owner_client.patch("/tournaments/#{tournament.fetch(:id)}/timer", body: {
|
||||||
|
timer_mode: 'countup',
|
||||||
|
timer_reason: 'custom',
|
||||||
|
timer_reason_text: 'Pause ran long'
|
||||||
|
})
|
||||||
timer_clear = owner_client.patch("/tournaments/#{tournament.fetch(:id)}/timer", body: { clear_timer: true })
|
timer_clear = owner_client.patch("/tournaments/#{tournament.fetch(:id)}/timer", body: { clear_timer: true })
|
||||||
timer_double = owner_client.patch("/tournaments/#{tournament.fetch(:id)}/timer", body: {
|
timer_double = owner_client.patch("/tournaments/#{tournament.fetch(:id)}/timer", body: {
|
||||||
timestamp_seconds: 120,
|
timestamp_seconds: 120,
|
||||||
timestamp: (Time.now.utc + 240).iso8601
|
timestamp: (Time.now.utc + 240).iso8601
|
||||||
})
|
})
|
||||||
timer_invalid_seconds = owner_client.patch("/tournaments/#{tournament.fetch(:id)}/timer", body: { timestamp_seconds: 'nope' })
|
timer_invalid_reason = owner_client.patch("/tournaments/#{tournament.fetch(:id)}/timer", body: {
|
||||||
|
timestamp_seconds: 120,
|
||||||
|
timer_reason: 'ceremony'
|
||||||
|
})
|
||||||
|
timer_custom_without_text = owner_client.patch("/tournaments/#{tournament.fetch(:id)}/timer", body: {
|
||||||
|
timestamp_seconds: 120,
|
||||||
|
timer_reason: 'custom'
|
||||||
|
})
|
||||||
|
timer_invalid_seconds = owner_client.patch("/tournaments/#{tournament.fetch(:id)}/timer", body: {
|
||||||
|
timestamp_seconds: 'nope'
|
||||||
|
})
|
||||||
timer_past = owner_client.patch("/tournaments/#{tournament.fetch(:id)}/timer", body: { timestamp_seconds: 0 })
|
timer_past = owner_client.patch("/tournaments/#{tournament.fetch(:id)}/timer", body: { timestamp_seconds: 0 })
|
||||||
timer_countup_seconds = owner_client.patch("/tournaments/#{tournament.fetch(:id)}/timer", body: {
|
timer_countup_seconds = owner_client.patch("/tournaments/#{tournament.fetch(:id)}/timer", body: {
|
||||||
timer_mode: 'countup',
|
timer_mode: 'countup',
|
||||||
|
|
@ -171,18 +188,31 @@ RSpec.describe 'Backend API surface HTTP E2E' do
|
||||||
expect(timer_seconds[:status]).to eq(200)
|
expect(timer_seconds[:status]).to eq(200)
|
||||||
expect(timer_seconds.dig(:json, :timestamp)).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_seconds.dig(:json, :timer_mode)).to eq('countdown')
|
||||||
|
expect(timer_seconds.dig(:json, :timer_reason)).to eq('tournament_start')
|
||||||
|
expect(timer_seconds.dig(:json, :timer_reason_text)).to be_nil
|
||||||
expect(timer_show[:status]).to eq(200)
|
expect(timer_show[:status]).to eq(200)
|
||||||
expect(timer_show.dig(:json, :timestamp)).to eq(timer_seconds.dig(:json, :timestamp))
|
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_show.dig(:json, :timer_mode)).to eq('countdown')
|
||||||
|
expect(timer_show.dig(:json, :timer_reason)).to eq('tournament_start')
|
||||||
expect(timer_countup[:status]).to eq(200)
|
expect(timer_countup[:status]).to eq(200)
|
||||||
expect(timer_countup.dig(:json, :timestamp)).to be_a(String)
|
expect(timer_countup.dig(:json, :timestamp)).to be_a(String)
|
||||||
expect(timer_countup.dig(:json, :timer_mode)).to eq('countup')
|
expect(timer_countup.dig(:json, :timer_mode)).to eq('countup')
|
||||||
|
expect(timer_countup.dig(:json, :timer_reason)).to eq('custom')
|
||||||
|
expect(timer_countup.dig(:json, :timer_reason_text)).to eq('Pause ran long')
|
||||||
expect(timer_clear[:status]).to eq(200)
|
expect(timer_clear[:status]).to eq(200)
|
||||||
expect(timer_clear.dig(:json, :timestamp)).to be_nil
|
expect(timer_clear.dig(:json, :timestamp)).to be_nil
|
||||||
expect(timer_clear.dig(:json, :timer_mode)).to be_nil
|
expect(timer_clear.dig(:json, :timer_mode)).to be_nil
|
||||||
|
expect(timer_clear.dig(:json, :timer_reason)).to be_nil
|
||||||
|
expect(timer_clear.dig(:json, :timer_reason_text)).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 timestamp or timestamp_seconds is allowed')
|
expect(timer_double.dig(:json, :error)).to eq('Only one of timestamp or timestamp_seconds is allowed')
|
||||||
|
expect(timer_invalid_reason[:status]).to eq(422)
|
||||||
|
expect(timer_invalid_reason.dig(:json, :error)).to eq('Invalid timer_reason')
|
||||||
|
expect(timer_custom_without_text[:status]).to eq(422)
|
||||||
|
expect(timer_custom_without_text.dig(:json, :error)).to eq(
|
||||||
|
'timer_reason_text is required for custom timer_reason'
|
||||||
|
)
|
||||||
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)
|
||||||
|
|
|
||||||
|
|
@ -504,6 +504,9 @@ RSpec.describe 'Tournament follower sync HTTP E2E' do
|
||||||
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),
|
||||||
timestamp: tournament[:timestamp],
|
timestamp: tournament[:timestamp],
|
||||||
|
timer_mode: tournament[:timer_mode],
|
||||||
|
timer_reason: tournament[:timer_reason],
|
||||||
|
timer_reason_text: tournament[:timer_reason_text],
|
||||||
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|
|
||||||
{
|
{
|
||||||
|
|
|
||||||
|
|
@ -40,24 +40,42 @@ RSpec.describe 'Tournament timer follower sync websocket HTTP E2E' do
|
||||||
initial_payload = cable_client.wait_for_timer_payload!
|
initial_payload = cable_client.wait_for_timer_payload!
|
||||||
expect(initial_payload.fetch('timestamp')).to be_nil
|
expect(initial_payload.fetch('timestamp')).to be_nil
|
||||||
expect(initial_payload.fetch('timer_mode')).to be_nil
|
expect(initial_payload.fetch('timer_mode')).to be_nil
|
||||||
|
expect(initial_payload.fetch('timer_reason')).to be_nil
|
||||||
|
expect(initial_payload.fetch('timer_reason_text')).to be_nil
|
||||||
|
|
||||||
countdown = source_client.patch("/tournaments/#{source.fetch(:id)}/timer", body: { timestamp_seconds: 120 })
|
countdown = source_client.patch("/tournaments/#{source.fetch(:id)}/timer", body: {
|
||||||
|
timestamp_seconds: 120,
|
||||||
|
timer_reason: 'match_end'
|
||||||
|
})
|
||||||
expect(countdown[:status]).to eq(200)
|
expect(countdown[:status]).to eq(200)
|
||||||
|
sync_now_if_requested!(source.fetch(:id))
|
||||||
|
|
||||||
countdown_payload = wait_for_timer_state!(cable_client, timer_mode: 'countdown')
|
countdown_payload = wait_for_timer_state!(cable_client, timer_mode: 'countdown')
|
||||||
expect(countdown_payload.fetch('timestamp')).to eq(countdown.dig(:json, :timestamp))
|
expect(countdown_payload.fetch('timestamp')).to eq(countdown.dig(:json, :timestamp))
|
||||||
|
expect(countdown_payload.fetch('timer_reason')).to eq('match_end')
|
||||||
|
expect(countdown_payload.fetch('timer_reason_text')).to be_nil
|
||||||
|
|
||||||
countup = source_client.patch("/tournaments/#{source.fetch(:id)}/timer", body: { timer_mode: 'countup' })
|
countup = source_client.patch("/tournaments/#{source.fetch(:id)}/timer", body: {
|
||||||
|
timer_mode: 'countup',
|
||||||
|
timer_reason: 'custom',
|
||||||
|
timer_reason_text: 'Follower sync custom reason'
|
||||||
|
})
|
||||||
expect(countup[:status]).to eq(200)
|
expect(countup[:status]).to eq(200)
|
||||||
|
sync_now_if_requested!(source.fetch(:id))
|
||||||
|
|
||||||
countup_payload = wait_for_timer_state!(cable_client, timer_mode: 'countup')
|
countup_payload = wait_for_timer_state!(cable_client, timer_mode: 'countup')
|
||||||
expect(countup_payload.fetch('timestamp')).to eq(countup.dig(:json, :timestamp))
|
expect(countup_payload.fetch('timestamp')).to eq(countup.dig(:json, :timestamp))
|
||||||
|
expect(countup_payload.fetch('timer_reason')).to eq('custom')
|
||||||
|
expect(countup_payload.fetch('timer_reason_text')).to eq('Follower sync custom reason')
|
||||||
|
|
||||||
clear = source_client.patch("/tournaments/#{source.fetch(:id)}/timer", body: { clear_timer: true })
|
clear = source_client.patch("/tournaments/#{source.fetch(:id)}/timer", body: { clear_timer: true })
|
||||||
expect(clear[:status]).to eq(200)
|
expect(clear[:status]).to eq(200)
|
||||||
|
sync_now_if_requested!(source.fetch(:id))
|
||||||
|
|
||||||
cleared_payload = wait_for_timer_state!(cable_client, timer_mode: nil)
|
cleared_payload = wait_for_timer_state!(cable_client, timer_mode: nil)
|
||||||
expect(cleared_payload.fetch('timestamp')).to be_nil
|
expect(cleared_payload.fetch('timestamp')).to be_nil
|
||||||
|
expect(cleared_payload.fetch('timer_reason')).to be_nil
|
||||||
|
expect(cleared_payload.fetch('timer_reason_text')).to be_nil
|
||||||
ensure
|
ensure
|
||||||
cable_client&.close
|
cable_client&.close
|
||||||
end
|
end
|
||||||
|
|
@ -95,6 +113,13 @@ RSpec.describe 'Tournament timer follower sync websocket HTTP E2E' do
|
||||||
response.fetch(:json)
|
response.fetch(:json)
|
||||||
end
|
end
|
||||||
|
|
||||||
|
def sync_now_if_requested!(source_tournament_id)
|
||||||
|
return unless ENV.fetch('TURNIERE_E2E_SYNC_MODE', nil) == 'manual'
|
||||||
|
|
||||||
|
response = source_client.post("/tournaments/#{source_tournament_id}/test_sync")
|
||||||
|
expect(response[:status]).to eq(200)
|
||||||
|
end
|
||||||
|
|
||||||
def wait_for_timer_state!(cable_client, timer_mode:, timeout: 20)
|
def wait_for_timer_state!(cable_client, timer_mode:, timeout: 20)
|
||||||
deadline = Time.now + timeout
|
deadline = Time.now + timeout
|
||||||
loop do
|
loop do
|
||||||
|
|
|
||||||
|
|
@ -31,24 +31,39 @@ RSpec.describe 'Tournament timer websocket HTTP E2E' do
|
||||||
initial_payload = cable_client.wait_for_timer_payload!
|
initial_payload = cable_client.wait_for_timer_payload!
|
||||||
expect(initial_payload.fetch('timestamp')).to be_nil
|
expect(initial_payload.fetch('timestamp')).to be_nil
|
||||||
expect(initial_payload.fetch('timer_mode')).to be_nil
|
expect(initial_payload.fetch('timer_mode')).to be_nil
|
||||||
|
expect(initial_payload.fetch('timer_reason')).to be_nil
|
||||||
|
expect(initial_payload.fetch('timer_reason_text')).to be_nil
|
||||||
|
|
||||||
countdown = client.patch("/tournaments/#{tournament.fetch(:id)}/timer", body: { timestamp_seconds: 120 })
|
countdown = client.patch("/tournaments/#{tournament.fetch(:id)}/timer", body: {
|
||||||
|
timestamp_seconds: 120,
|
||||||
|
timer_reason: 'playoff_start'
|
||||||
|
})
|
||||||
expect(countdown[:status]).to eq(200)
|
expect(countdown[:status]).to eq(200)
|
||||||
|
|
||||||
countdown_payload = wait_for_timer_state!(cable_client, timer_mode: 'countdown')
|
countdown_payload = wait_for_timer_state!(cable_client, timer_mode: 'countdown')
|
||||||
expect(countdown_payload.fetch('timestamp')).to eq(countdown.dig(:json, :timestamp))
|
expect(countdown_payload.fetch('timestamp')).to eq(countdown.dig(:json, :timestamp))
|
||||||
|
expect(countdown_payload.fetch('timer_reason')).to eq('playoff_start')
|
||||||
|
expect(countdown_payload.fetch('timer_reason_text')).to be_nil
|
||||||
|
|
||||||
countup = client.patch("/tournaments/#{tournament.fetch(:id)}/timer", body: { timer_mode: 'countup' })
|
countup = client.patch("/tournaments/#{tournament.fetch(:id)}/timer", body: {
|
||||||
|
timer_mode: 'countup',
|
||||||
|
timer_reason: 'custom',
|
||||||
|
timer_reason_text: 'Manual overtime'
|
||||||
|
})
|
||||||
expect(countup[:status]).to eq(200)
|
expect(countup[:status]).to eq(200)
|
||||||
|
|
||||||
countup_payload = wait_for_timer_state!(cable_client, timer_mode: 'countup')
|
countup_payload = wait_for_timer_state!(cable_client, timer_mode: 'countup')
|
||||||
expect(countup_payload.fetch('timestamp')).to eq(countup.dig(:json, :timestamp))
|
expect(countup_payload.fetch('timestamp')).to eq(countup.dig(:json, :timestamp))
|
||||||
|
expect(countup_payload.fetch('timer_reason')).to eq('custom')
|
||||||
|
expect(countup_payload.fetch('timer_reason_text')).to eq('Manual overtime')
|
||||||
|
|
||||||
clear = client.patch("/tournaments/#{tournament.fetch(:id)}/timer", body: { clear_timer: true })
|
clear = client.patch("/tournaments/#{tournament.fetch(:id)}/timer", body: { clear_timer: true })
|
||||||
expect(clear[:status]).to eq(200)
|
expect(clear[:status]).to eq(200)
|
||||||
|
|
||||||
cleared_payload = wait_for_timer_state!(cable_client, timer_mode: nil)
|
cleared_payload = wait_for_timer_state!(cable_client, timer_mode: nil)
|
||||||
expect(cleared_payload.fetch('timestamp')).to be_nil
|
expect(cleared_payload.fetch('timestamp')).to be_nil
|
||||||
|
expect(cleared_payload.fetch('timer_reason')).to be_nil
|
||||||
|
expect(cleared_payload.fetch('timer_reason_text')).to be_nil
|
||||||
ensure
|
ensure
|
||||||
cable_client&.close
|
cable_client&.close
|
||||||
end
|
end
|
||||||
|
|
|
||||||
|
|
@ -83,4 +83,27 @@ RSpec.describe 'Tournament live payloads' do
|
||||||
expect(payload[:matches]).to eq(TournamentMatchesPayload.build(tournament, state: 'not_started'))
|
expect(payload[:matches]).to eq(TournamentMatchesPayload.build(tournament, state: 'not_started'))
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
|
describe TournamentTimerBroadcastPayload do
|
||||||
|
it 'serializes timer reason fields for websocket subscribers' do
|
||||||
|
tournament = create(
|
||||||
|
:tournament,
|
||||||
|
timestamp: Time.utc(2026, 4, 24, 10, 15, 0),
|
||||||
|
timer_mode: 'countdown',
|
||||||
|
timer_reason: 'custom',
|
||||||
|
timer_reason_text: 'Opening ceremony'
|
||||||
|
)
|
||||||
|
|
||||||
|
payload = described_class.build(tournament)
|
||||||
|
|
||||||
|
expect(payload).to include(
|
||||||
|
type: 'timer.updated',
|
||||||
|
tournament_id: tournament.id,
|
||||||
|
timestamp: '2026-04-24T10:15:00Z',
|
||||||
|
timer_mode: 'countdown',
|
||||||
|
timer_reason: 'custom',
|
||||||
|
timer_reason_text: 'Opening ceremony'
|
||||||
|
)
|
||||||
|
end
|
||||||
|
end
|
||||||
end
|
end
|
||||||
|
|
|
||||||
|
|
@ -6,6 +6,12 @@ RSpec.describe TournamentSnapshotImporter do
|
||||||
describe '.import!' do
|
describe '.import!' do
|
||||||
it 'replaces follower tournament graph using source ids as stable mapping keys' do
|
it 'replaces follower tournament graph using source ids as stable mapping keys' do
|
||||||
source = create(:group_stage_tournament, group_count: 2, match_factory: :filled_group_match)
|
source = create(:group_stage_tournament, group_count: 2, match_factory: :filled_group_match)
|
||||||
|
source.update!(
|
||||||
|
timestamp: Time.utc(2026, 4, 24, 10, 15, 0),
|
||||||
|
timer_mode: 'countdown',
|
||||||
|
timer_reason: 'custom',
|
||||||
|
timer_reason_text: 'Opening ceremony'
|
||||||
|
)
|
||||||
source.beamers.create!(
|
source.beamers.create!(
|
||||||
name: 'stream-a',
|
name: 'stream-a',
|
||||||
display_state: 'during_game',
|
display_state: 'during_game',
|
||||||
|
|
@ -23,6 +29,8 @@ RSpec.describe TournamentSnapshotImporter do
|
||||||
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.timer_mode).to eq(source.timer_mode)
|
||||||
|
expect(follower.timer_reason).to eq(source.timer_reason)
|
||||||
|
expect(follower.timer_reason_text).to eq(source.timer_reason_text)
|
||||||
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)
|
||||||
|
|
@ -127,6 +135,11 @@ RSpec.describe TournamentSnapshotImporter do
|
||||||
|
|
||||||
tournament.reload
|
tournament.reload
|
||||||
play_some_playoff_matches!(tournament)
|
play_some_playoff_matches!(tournament)
|
||||||
|
tournament.update!(
|
||||||
|
timestamp: Time.utc(2026, 4, 24, 10, 15, 0),
|
||||||
|
timer_mode: 'countdown',
|
||||||
|
timer_reason: 'playoff_start'
|
||||||
|
)
|
||||||
tournament.reload
|
tournament.reload
|
||||||
end
|
end
|
||||||
|
|
||||||
|
|
@ -186,6 +199,8 @@ RSpec.describe TournamentSnapshotImporter do
|
||||||
intermediate_round_participants_amount: tournament.intermediate_round_participants_amount,
|
intermediate_round_participants_amount: tournament.intermediate_round_participants_amount,
|
||||||
timestamp: tournament.timestamp&.iso8601,
|
timestamp: tournament.timestamp&.iso8601,
|
||||||
timer_mode: tournament.timer_mode,
|
timer_mode: tournament.timer_mode,
|
||||||
|
timer_reason: tournament.timer_reason,
|
||||||
|
timer_reason_text: tournament.timer_reason_text,
|
||||||
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|
|
||||||
{
|
{
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue