Merge remote-tracking branch 'origin/master' into codex/tur-130-dependency-update-a31c

This commit is contained in:
Daniel Schädler 2026-04-29 12:14:34 +02:00
commit 8855532660
18 changed files with 695 additions and 17 deletions

View File

@ -2,5 +2,12 @@
module ApplicationCable
class Connection < ActionCable::Connection::Base
def connect
MetricsRegistry.action_cable_connected
end
def disconnect
MetricsRegistry.action_cable_disconnected
end
end
end

View File

@ -3,6 +3,7 @@
class ApplicationController < ActionController::API
include DeviseTokenAuth::Concerns::SetUserByToken
around_action :record_request_metrics
before_action :configure_permitted_parameters, if: :devise_controller?
rescue_from ActionController::ParameterMissing do |e|
@ -19,6 +20,23 @@ class ApplicationController < ActionController::API
private
def record_request_metrics
start_time = Process.clock_gettime(Process::CLOCK_MONOTONIC)
metrics_status = nil
yield
rescue StandardError => e
metrics_status = ActionDispatch::ExceptionWrapper.status_code_for_exception(e.class.name)
raise
ensure
MetricsRegistry.record_http_request(
method: request.request_method,
controller: controller_path,
action: action_name,
status: metrics_status || response.status,
duration: Process.clock_gettime(Process::CLOCK_MONOTONIC) - start_time
)
end
def require_owner!(owner)
render_forbidden_error if owner != current_user
end

View File

@ -0,0 +1,8 @@
# frozen_string_literal: true
class MetricsController < ApplicationController
def index
render plain: MetricsRegistry.render_prometheus,
content_type: 'text/plain; version=0.0.4'
end
end

View File

@ -12,15 +12,18 @@ class TournamentsController < ApplicationController
end
before_action :set_tournament_for_show, only: %i[show]
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 test_sync]
before_action -> { require_owner! @tournament.owner }, only: %i[update destroy set_timer test_sync]
before_action :set_tournament,
only: %i[update destroy set_timer timer sync_state test_sync start_next_group_matches]
before_action :authenticate_user!, only: %i[create update destroy set_timer test_sync start_next_group_matches]
before_action -> { require_owner! @tournament.owner },
only: %i[update destroy set_timer test_sync start_next_group_matches]
before_action :validate_create_params, only: %i[create]
before_action :validate_update_params, only: %i[update]
before_action :validate_set_timer_params, only: %i[set_timer]
before_action :ensure_update_allowed!, only: %i[update]
before_action :ensure_destroy_allowed!, only: %i[destroy]
before_action :ensure_timer_update_allowed!, only: %i[set_timer]
before_action -> { require_writable_tournament!(@tournament) }, only: %i[start_next_group_matches]
before_action :authorize_sync_request!, only: %i[sync_state]
rescue_from ActiveRecord::RecordNotFound, with: :render_not_found_error
@ -165,6 +168,20 @@ class TournamentsController < ApplicationController
}, status: :bad_gateway
end
# PATCH /tournaments/:id/start_next_group_matches
def start_next_group_matches
started_matches = TournamentGroupMatchStarter.start_next!(@tournament, logger:)
if started_matches.any?
push_sync_if_needed!(@tournament)
TournamentLiveBroadcast.call(@tournament.id)
end
render json: ActiveModelSerializers::SerializableResource.new(
started_matches,
each_serializer: LiveMatchSerializer,
include: %w[match_scores.team]
).as_json
end
private

View File

@ -0,0 +1,212 @@
# frozen_string_literal: true
class MetricsRegistry
HTTP_LABELS = %i[method controller action status].freeze
REQUEST_DURATION_BUCKETS = [0.005, 0.01, 0.025, 0.05, 0.1, 0.25, 0.5, 1, 2.5, 5, 10].freeze
STARTED_AT = Process.clock_gettime(Process::CLOCK_MONOTONIC)
class << self
def record_http_request(method:, controller:, action:, status:, duration:)
labels = http_labels(method:, controller:, action:, status:)
synchronize do
@http_requests[labels] += 1
histogram = @http_request_duration[labels]
histogram[:count] += 1
histogram[:sum] += duration.to_f
REQUEST_DURATION_BUCKETS.each do |bucket|
histogram[:buckets][bucket] += 1 if duration.to_f <= bucket
end
end
end
def action_cable_connected
synchronize do
@action_cable_connections += 1
@action_cable_connections_total += 1
end
end
def action_cable_disconnected
synchronize do
@action_cable_connections = [@action_cable_connections - 1, 0].max
end
end
def render_prometheus
snapshot = snapshot_metrics
PrometheusRenderer.new(snapshot).render
end
def reset!
@mutex = Mutex.new
@http_requests = Hash.new(0)
@http_request_duration = Hash.new do |hash, labels|
hash[labels] = {
count: 0,
sum: 0.0,
buckets: REQUEST_DURATION_BUCKETS.index_with(0)
}
end
@action_cable_connections = 0
@action_cable_connections_total = 0
end
private
def synchronize(&block)
reset! if @mutex.nil?
@mutex.synchronize(&block)
end
def snapshot_metrics
synchronize do
{
http_requests: @http_requests.dup,
http_request_duration: @http_request_duration.transform_values do |histogram|
{
count: histogram.fetch(:count),
sum: histogram.fetch(:sum),
buckets: histogram.fetch(:buckets).dup
}
end,
action_cable_connections: @action_cable_connections,
action_cable_connections_total: @action_cable_connections_total,
uptime_seconds: Process.clock_gettime(Process::CLOCK_MONOTONIC) - STARTED_AT,
active_record_pool: active_record_pool_stats,
git_commit_sha: ENV.fetch('GIT_COMMIT_SHA', 'unknown')
}
end
end
def http_labels(method:, controller:, action:, status:)
{
method: method.to_s.upcase,
controller: controller.to_s,
action: action.to_s,
status: status.to_i.to_s
}.freeze
end
def active_record_pool_stats
return {} unless defined?(ActiveRecord::Base)
ActiveRecord::Base.connection_pool.stat
rescue StandardError
{}
end
end
class PrometheusRenderer
def initialize(snapshot)
@snapshot = snapshot
@lines = []
end
def render
render_build_info
render_http_requests
render_http_request_duration
render_action_cable_connections
render_process_metrics
render_active_record_pool
"#{lines.join("\n")}\n"
end
private
attr_reader :snapshot, :lines
def render_build_info
help('turniere_build_info', 'Build metadata for this process.')
type('turniere_build_info', 'gauge')
sample('turniere_build_info', 1, git_commit_sha: snapshot.fetch(:git_commit_sha))
end
def render_http_requests
help('turniere_http_requests_total', 'Total HTTP requests handled by Rails controllers.')
type('turniere_http_requests_total', 'counter')
snapshot.fetch(:http_requests).each do |labels, count|
sample('turniere_http_requests_total', count, labels)
end
end
def render_http_request_duration
help('turniere_http_request_duration_seconds', 'HTTP request duration in seconds.')
type('turniere_http_request_duration_seconds', 'histogram')
snapshot.fetch(:http_request_duration).each do |labels, histogram|
histogram.fetch(:buckets).each do |bucket, count|
sample('turniere_http_request_duration_seconds_bucket', count, labels.merge(le: format_number(bucket)))
end
sample('turniere_http_request_duration_seconds_bucket', histogram.fetch(:count), labels.merge(le: '+Inf'))
sample('turniere_http_request_duration_seconds_sum', histogram.fetch(:sum), labels)
sample('turniere_http_request_duration_seconds_count', histogram.fetch(:count), labels)
end
end
def render_action_cable_connections
help('turniere_action_cable_connections', 'Currently open ActionCable websocket connections.')
type('turniere_action_cable_connections', 'gauge')
sample('turniere_action_cable_connections', snapshot.fetch(:action_cable_connections))
help('turniere_action_cable_connections_total', 'Total ActionCable websocket connections opened.')
type('turniere_action_cable_connections_total', 'counter')
sample('turniere_action_cable_connections_total', snapshot.fetch(:action_cable_connections_total))
end
def render_process_metrics
help('turniere_process_uptime_seconds', 'Process uptime in seconds.')
type('turniere_process_uptime_seconds', 'gauge')
sample('turniere_process_uptime_seconds', snapshot.fetch(:uptime_seconds))
help('turniere_ruby_threads', 'Ruby threads visible in this process.')
type('turniere_ruby_threads', 'gauge')
sample('turniere_ruby_threads', Thread.list.count)
end
def render_active_record_pool
help('turniere_active_record_connection_pool', 'ActiveRecord connection pool state.')
type('turniere_active_record_connection_pool', 'gauge')
snapshot.fetch(:active_record_pool).each do |state, value|
sample('turniere_active_record_connection_pool', value, state:)
end
end
def help(name, text)
lines << "# HELP #{name} #{text}"
end
def type(name, type)
lines << "# TYPE #{name} #{type}"
end
def sample(name, value, labels = {})
lines << "#{name}#{labels_string(labels)} #{format_number(value)}"
end
def labels_string(labels)
return '' if labels.empty?
formatted = labels.map do |key, value|
%(#{key}="#{escape_label_value(value)}")
end
"{#{formatted.join(',')}}"
end
def escape_label_value(value)
value.to_s.gsub('\\', '\\\\\\').gsub("\n", '\\n').gsub('"', '\"')
end
def format_number(value)
return value.to_s if value.is_a?(Integer)
format('%.12g', value)
end
end
end
MetricsRegistry.reset!

View File

@ -0,0 +1,41 @@
# frozen_string_literal: true
class TournamentGroupMatchStarter
class << self
def upcoming_matches(tournament)
group_stage = tournament.group_stage
return [] if group_stage.nil?
next_matches = group_stage.groups.filter_map do |group|
group.matches.not_started.order(:position).first
end
lowest_position = next_matches.map(&:position).min
return [] if lowest_position.nil?
next_matches.select { |match| match.position == lowest_position }
.sort_by { |match| [match.position, match.group.number, match.id] }
end
def start_next!(tournament, logger: Rails.logger)
started_matches = []
Match.transaction do
started_matches = upcoming_matches(tournament)
started_matches.each { |match| match.update!(state: :in_progress) }
recalculate_group_scores!(started_matches, logger:)
end
started_matches
end
private
def recalculate_group_scores!(matches, logger:)
matches.map(&:group).uniq.each do |group|
next if UpdateGroupsGroupScoresAndSave.call(group:).success?
logger.warn "Updating groups group score failed for #{group}"
end
end
end
end

View File

@ -79,12 +79,7 @@ class TournamentMatchesPayload
end
def upcoming_group_matches(tournament)
group_stage = tournament.stages.find { |stage| stage.level == -1 }
return [] if group_stage.nil?
group_stage.groups.filter_map do |group|
group.matches.select { |match| match.state == 'not_started' }.min_by(&:position)
end
TournamentGroupMatchStarter.upcoming_matches(tournament)
end
def upcoming_playoff_matches(tournament)

View File

@ -34,10 +34,12 @@ Rails.application.routes.draw do
get :timer
patch :timer, action: :set_timer
patch :sync_state
patch :start_next_group_matches
post :test_sync
end
end
resources :match_scores, only: %i[show update]
get 'healthz', to: 'health#index'
get 'metrics', to: 'metrics#index'
get 'version', to: 'version#show'
end

20
doc/metrics.md Normal file
View File

@ -0,0 +1,20 @@
# Metrics
The backend exposes Prometheus text metrics at `GET /metrics`.
The endpoint is unauthenticated so monitoring systems can scrape it directly. It
reports per-process in-memory values:
- `turniere_http_requests_total`
- `turniere_http_request_duration_seconds`
- `turniere_action_cable_connections`
- `turniere_action_cable_connections_total`
- `turniere_process_uptime_seconds`
- `turniere_ruby_threads`
- `turniere_active_record_connection_pool`
- `turniere_build_info`
This covers app-level request and websocket health. Logs, traces, host metrics,
and multi-process aggregation should remain infrastructure concerns. If Puma is
ever run with multiple worker processes, scrape each process or container replica
and aggregate in the monitoring backend.

View File

@ -888,4 +888,54 @@ RSpec.describe TournamentsController, type: :controller do
expect(json.dig(:tournament, :sync_last_push_error)).to eq('connection refused')
end
end
describe 'PATCH #start_next_group_matches' do
let(:tournament) { create(:group_stage_tournament, group_count: 4, match_factory: :filled_group_match) }
let(:groups) { tournament.group_stage.groups.sort_by(&:number) }
before do
set_group_match_states(groups[0], %i[finished finished finished in_progress])
groups.drop(1).each do |group|
set_group_match_states(group, %i[finished finished in_progress not_started])
end
end
context 'as owner' do
before do
apply_authentication_headers_for tournament.owner
end
it 'starts only lowest pending catch-up matches and returns them' do
expect(TournamentLiveBroadcast).to receive(:call).with(tournament.id)
patch :start_next_group_matches, params: { id: tournament.to_param }
expect(response).to have_http_status(:ok)
body = deserialize_response(response)
expect(body.map { |match| match.dig(:group, :id) }).to match_array(groups.drop(1).map(&:id))
expect(body.map { |match| match[:position] }.uniq).to eq([3])
expect(body.map { |match| match[:state] }.uniq).to eq(['in_progress'])
expect(groups.drop(1).map { |group| group.matches.find_by!(position: 3).reload.state }.uniq)
.to eq(['in_progress'])
end
end
context 'as another user' do
before do
apply_authentication_headers_for create(:user)
end
it 'renders a forbidden error response' do
patch :start_next_group_matches, params: { id: tournament.to_param }
expect(response).to have_http_status(:forbidden)
end
end
def set_group_match_states(group, states)
group.matches.order(:id).each_with_index do |match, index|
match.update!(position: index, state: states.fetch(index))
end
end
end
end

View File

@ -376,7 +376,6 @@ RSpec.describe 'Backend API surface HTTP E2E' do
group = group_stage.fetch(:groups).min_by { |item| item.fetch(:number) }
ordered_matches = group.fetch(:matches).sort_by { |item| item.fetch(:position) }
first_match = ordered_matches.fetch(0)
second_match = ordered_matches.fetch(1)
fourth_match = ordered_matches.fetch(3)
upcoming_before = owner_client.get("/tournaments/#{tournament.fetch(:id)}/matches", params: { state: 'upcoming' })
@ -408,7 +407,48 @@ RSpec.describe 'Backend API surface HTTP E2E' do
expect(upcoming_match_for_group(upcoming_after_swap, group_id: group.fetch(:id)).fetch(:id)).to eq(fourth_match.fetch(:id))
expect(upcoming_after_finish[:status]).to eq(200)
expect(upcoming_match_for_group(upcoming_after_finish, group_id: group.fetch(:id)).fetch(:id)).to eq(second_match.fetch(:id))
expect(upcoming_match_for_group(upcoming_after_finish, group_id: group.fetch(:id))).to be_nil
expect(upcoming_after_finish[:json].map { |match| match.fetch(:position) }.uniq)
.to eq([first_match.fetch(:position)])
end
it 'starts next group matches at the lowest pending position and returns started matches' do
tournament = create_group_stage_tournament(client: owner_client, public: false, name_prefix: 'Start Next Groups')
groups = tournament.fetch(:stages).find { |stage| stage.fetch(:level) == -1 }
.fetch(:groups)
.sort_by { |group| group.fetch(:number) }
matches_by_group = groups.to_h do |group|
[group.fetch(:id), group.fetch(:matches).sort_by { |match| match.fetch(:position) }]
end
first_start = owner_client.patch("/tournaments/#{tournament.fetch(:id)}/start_next_group_matches")
expect(first_start[:status]).to eq(200)
expect(first_start[:json].map { |match| match.dig(:group, :id) })
.to match_array(groups.map { |group| group.fetch(:id) })
expect(first_start[:json].map { |match| match.fetch(:position) }.uniq).to eq([0])
matches_by_group.fetch(groups.first.fetch(:id))[1..3].each do |match|
start_group_match!(match_id: match.fetch(:id))
end
groups.drop(1).each do |group|
matches_by_group.fetch(group.fetch(:id))[1..2].each do |match|
start_group_match!(match_id: match.fetch(:id))
end
end
expected_match_ids = groups.drop(1).map do |group|
matches_by_group.fetch(group.fetch(:id)).fetch(3).fetch(:id)
end
upcoming = owner_client.get("/tournaments/#{tournament.fetch(:id)}/matches", params: { state: 'upcoming' })
catch_up_start = owner_client.patch("/tournaments/#{tournament.fetch(:id)}/start_next_group_matches")
expect(upcoming[:status]).to eq(200)
expect(upcoming[:json].map { |match| match.fetch(:id) }).to match_array(expected_match_ids)
expect(catch_up_start[:status]).to eq(200)
expect(catch_up_start[:json].map { |match| match.fetch(:id) }).to match_array(expected_match_ids)
expect(catch_up_start[:json].map { |match| match.fetch(:position) }.uniq).to eq([3])
expect(catch_up_start[:json].map { |match| match.fetch(:state) }.uniq).to eq(['in_progress'])
end
def login_client(email:, password:)
@ -506,6 +546,11 @@ RSpec.describe 'Backend API surface HTTP E2E' do
expect(finish_response[:status]).to eq(200)
end
def start_group_match!(match_id:)
start_response = owner_client.patch("/matches/#{match_id}", body: { state: 'in_progress' })
expect(start_response[:status]).to eq(200)
end
def tournament_match_count(tournament)
tournament.fetch(:stages).sum do |stage|
stage.fetch(:matches, []).count + stage.fetch(:groups, []).sum { |group| group.fetch(:matches).count }

View File

@ -0,0 +1,97 @@
# frozen_string_literal: true
require 'spec_helper'
require 'securerandom'
require_relative '../../../e2e/lib/action_cable_client'
require_relative '../../../e2e/lib/api_client'
RSpec.describe 'Metrics HTTP E2E' do
primary_env_vars = %w[TURNIERE_E2E_EMAIL TURNIERE_E2E_PASSWORD].freeze
before do
missing_vars = primary_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', 'http://127.0.0.1:3000') }
let(:owner_email) { ENV.fetch('TURNIERE_E2E_EMAIL') }
let(:owner_password) { ENV.fetch('TURNIERE_E2E_PASSWORD') }
let(:anonymous_client) { TurniereE2E::ApiClient.new(base_url:) }
it 'exposes HTTP request metrics and live websocket connection metrics' do
health_response = anonymous_client.get('/healthz')
metrics_response = anonymous_client.get('/metrics')
expect(health_response[:status]).to eq(200)
expect(metrics_response[:status]).to eq(200)
expect(metrics_response.dig(:headers, 'content-type')).to include('text/plain')
metrics_body = raw_body(metrics_response)
expect(metrics_body).to include('# TYPE turniere_http_requests_total counter')
expect(metrics_body).to match(
/turniere_http_requests_total\{
(?=[^}]*method="GET")
(?=[^}]*controller="health")
(?=[^}]*action="index")
(?=[^}]*status="200")
[^}]*
\}\ [1-9]\d*/x
)
expect(metrics_body).to include('# TYPE turniere_http_request_duration_seconds histogram')
tournament = create_playoff_tournament
cable_client = TurniereE2E::ActionCableClient.new(base_url:)
cable_client.subscribe_timer!(tournament_id: tournament.fetch(:id))
expect(metric_value('turniere_action_cable_connections')).to be >= 1
expect(metric_value('turniere_action_cable_connections_total')).to be >= 1
ensure
cable_client&.close
end
def create_playoff_tournament
owner_client = login_client
unique = SecureRandom.hex(3)
response = owner_client.post('/tournaments', body: {
name: "Metrics #{unique}",
public: false,
teams: Array.new(4) { |index| { name: "Metrics-#{unique}-#{index + 1}" } }
})
expect(response[:status]).to eq(201)
response.fetch(:json)
end
def login_client
client = TurniereE2E::ApiClient.new(base_url:)
response = client.login!(email: owner_email, password: owner_password)
expect(response[:status]).to eq(200)
client
end
def metric_value(metric_name)
deadline = Process.clock_gettime(Process::CLOCK_MONOTONIC) + 10
loop do
value = latest_metrics_lines
.grep(/\A#{Regexp.escape(metric_name)}(?:\{| )/)
.map { |line| line.split.last.to_f }
.max
return value unless value.nil?
raise "#{metric_name} not reported" if Process.clock_gettime(Process::CLOCK_MONOTONIC) >= deadline
sleep 0.2
end
end
def latest_metrics_lines
response = anonymous_client.get('/metrics')
expect(response[:status]).to eq(200)
raw_body(response).lines.map(&:strip)
end
def raw_body(response)
response.dig(:json, :raw_body).to_s
end
end

View File

@ -0,0 +1,28 @@
# frozen_string_literal: true
require 'rails_helper'
RSpec.describe 'Metrics endpoint', type: :request do
before do
MetricsRegistry.reset!
end
it 'renders Prometheus text without authentication' do
get '/metrics'
expect(response).to have_http_status(:ok)
expect(response.media_type).to include('text/plain')
expect(response.body).to include('# TYPE turniere_build_info gauge')
expect(response.body).to include('# TYPE turniere_http_requests_total counter')
expect(response.body).to include('turniere_action_cable_connections 0')
end
it 'includes request metrics from previous controller requests' do
get '/healthz'
get '/metrics'
expect(response.body).to include(
'turniere_http_requests_total{method="GET",controller="health",action="index",status="200"} 1'
)
end
end

View File

@ -0,0 +1,11 @@
# frozen_string_literal: true
require 'rails_helper'
RSpec.describe MetricsController, type: :routing do
describe 'routing' do
it 'routes to #index' do
expect(get: '/metrics').to route_to('metrics#index')
end
end
end

View File

@ -32,6 +32,12 @@ RSpec.describe TournamentsController, type: :routing do
expect(patch: '/tournaments/1/sync_state').to route_to('tournaments#sync_state', id: '1')
end
it 'routes to #start_next_group_matches' do
expect(patch: '/tournaments/1/start_next_group_matches').to(
route_to('tournaments#start_next_group_matches', id: '1')
)
end
it 'routes to #test_sync' do
expect(post: '/tournaments/1/test_sync').to route_to('tournaments#test_sync', id: '1')
end

View File

@ -0,0 +1,59 @@
# frozen_string_literal: true
require 'rails_helper'
RSpec.describe MetricsRegistry do
let(:health_labels) { 'method="GET",controller="health",action="index",status="200"' }
before do
described_class.reset!
end
describe '.record_http_request' do
it 'renders Prometheus counters and duration histogram buckets' do
described_class.record_http_request(
method: 'get',
controller: 'health',
action: 'index',
status: 200,
duration: 0.012
)
metrics = described_class.render_prometheus
expect(metrics).to include(
"turniere_http_requests_total{#{health_labels}} 1"
)
expect(metrics).to include(
"turniere_http_request_duration_seconds_bucket{#{health_labels},le=\"0.025\"} 1"
)
expect(metrics).to include(
"turniere_http_request_duration_seconds_count{#{health_labels}} 1"
)
expect(metrics).to match(
/turniere_http_request_duration_seconds_sum\{#{health_labels}\} 0\.012/
)
end
end
describe 'ActionCable connection metrics' do
it 'tracks open and total websocket connections' do
described_class.action_cable_connected
described_class.action_cable_connected
described_class.action_cable_disconnected
metrics = described_class.render_prometheus
expect(metrics).to include("turniere_action_cable_connections 1\n")
expect(metrics).to include("turniere_action_cable_connections_total 2\n")
end
it 'does not let open websocket connections become negative' do
described_class.action_cable_disconnected
metrics = described_class.render_prometheus
expect(metrics).to include("turniere_action_cable_connections 0\n")
end
end
end

View File

@ -0,0 +1,41 @@
# frozen_string_literal: true
require 'rails_helper'
RSpec.describe TournamentGroupMatchStarter do
let(:tournament) { create(:group_stage_tournament, group_count: 4, match_factory: :filled_group_match) }
let(:groups) { tournament.group_stage.groups.sort_by(&:number) }
before do
set_group_match_states(groups[0], %i[finished finished finished in_progress])
groups.drop(1).each do |group|
set_group_match_states(group, %i[finished finished in_progress not_started])
end
end
describe '.upcoming_matches' do
it 'returns only groups whose next unstarted match is at the lowest pending position' do
matches = described_class.upcoming_matches(tournament)
expect(matches.map(&:group)).to match_array(groups.drop(1))
expect(matches.map(&:position).uniq).to eq([3])
end
end
describe '.start_next!' do
it 'starts selected catch-up matches and returns them' do
started_matches = described_class.start_next!(tournament, logger: Logger.new(nil))
expect(started_matches.map(&:group)).to match_array(groups.drop(1))
expect(started_matches.map(&:position).uniq).to eq([3])
expect(started_matches.map { |match| match.reload.state }.uniq).to eq(['in_progress'])
expect(groups.first.matches.find_by!(position: 3).reload).to be_in_progress
end
end
def set_group_match_states(group, states)
group.matches.order(:id).each_with_index do |match, index|
match.update!(position: index, state: states.fetch(index))
end
end
end

View File

@ -49,6 +49,21 @@ RSpec.describe 'Tournament live payloads' do
expect(matches.map(&:group_id)).to match_array(tournament.group_stage.groups.map(&:id))
end
it 'returns only catch-up group matches for upcoming state' do
tournament = create(:group_stage_tournament, group_count: 4, match_factory: :filled_group_match)
groups = tournament.group_stage.groups.sort_by(&:number)
set_group_match_states(groups[0], %i[finished finished finished in_progress])
groups.drop(1).each do |group|
set_group_match_states(group, %i[finished finished in_progress not_started])
end
matches = described_class.matches_for(tournament, state: 'upcoming')
expect(matches.map(&:group_id)).to match_array(groups.drop(1).map(&:id))
expect(matches.map(&:position).uniq).to eq([3])
end
it 'filters all matches belonging to a stage level' do
tournament = create(:stage_tournament, stage_count: 2)
stage = tournament.stages.find_by!(level: 1)
@ -106,4 +121,10 @@ RSpec.describe 'Tournament live payloads' do
)
end
end
def set_group_match_states(group, states)
group.matches.order(:id).each_with_index do |match, index|
match.update!(position: index, state: states.fetch(index))
end
end
end