fix: refresh RuboCop cleanup
This commit is contained in:
commit
8951070a15
|
|
@ -70,13 +70,17 @@ rails e2e spec:
|
|||
alias: postgres
|
||||
- name: $CI_REGISTRY_IMAGE/production/commits:$CI_COMMIT_SHA
|
||||
alias: app
|
||||
variables:
|
||||
TURNIERE_DISABLE_EMAIL_DELIVERY: "1"
|
||||
WEB_CONCURRENCY: "1"
|
||||
RAILS_MAX_THREADS: "5"
|
||||
RAILS_DB_POOL: "5"
|
||||
SIDEKIQ_CONCURRENCY: "5"
|
||||
command:
|
||||
- bundle
|
||||
- exec
|
||||
- rails
|
||||
- s
|
||||
- -p
|
||||
- "3000"
|
||||
- -b
|
||||
- 0.0.0.0
|
||||
variables:
|
||||
|
|
@ -84,6 +88,7 @@ rails e2e spec:
|
|||
POSTGRES_DB: turniere_blackbox
|
||||
POSTGRES_USER: turniere
|
||||
POSTGRES_PASSWORD: turniere
|
||||
DATABASE_URL: postgres://turniere:turniere@postgres:5432/turniere_blackbox
|
||||
POSTGRES_URL: postgres://turniere:turniere@postgres:5432/turniere_blackbox
|
||||
POSTGRES_USERNAME: turniere
|
||||
SECRET_KEY_BASE: turniere-blackbox-secret-key-base-please-change-in-real-prod
|
||||
|
|
@ -111,6 +116,69 @@ rails e2e spec:
|
|||
- cd /app
|
||||
- inv blackbox-service
|
||||
|
||||
rails load smoke:
|
||||
stage: e2e
|
||||
tags:
|
||||
- docker
|
||||
needs:
|
||||
- job: build_image
|
||||
image: $CI_REGISTRY_IMAGE/test/commits:$CI_COMMIT_SHA
|
||||
services:
|
||||
- name: postgres:16
|
||||
alias: postgres
|
||||
- name: $CI_REGISTRY_IMAGE/production/commits:$CI_COMMIT_SHA
|
||||
alias: app
|
||||
variables:
|
||||
TURNIERE_DISABLE_EMAIL_DELIVERY: "1"
|
||||
WEB_CONCURRENCY: "1"
|
||||
RAILS_MAX_THREADS: "5"
|
||||
RAILS_DB_POOL: "5"
|
||||
SIDEKIQ_CONCURRENCY: "5"
|
||||
command:
|
||||
- bundle
|
||||
- exec
|
||||
- rails
|
||||
- s
|
||||
- -b
|
||||
- 0.0.0.0
|
||||
variables:
|
||||
FF_NETWORK_PER_BUILD: "true"
|
||||
POSTGRES_DB: turniere_blackbox
|
||||
POSTGRES_USER: turniere
|
||||
POSTGRES_PASSWORD: turniere
|
||||
DATABASE_URL: postgres://turniere:turniere@postgres:5432/turniere_blackbox
|
||||
POSTGRES_URL: postgres://turniere:turniere@postgres:5432/turniere_blackbox
|
||||
POSTGRES_USERNAME: turniere
|
||||
SECRET_KEY_BASE: turniere-blackbox-secret-key-base-please-change-in-real-prod
|
||||
MAILGUN_API_KEY: blackbox-test-api-key
|
||||
MAILGUN_DOMAIN: blackbox.example.com
|
||||
RAILS_LOG_TO_STDOUT: "1"
|
||||
RAILS_SERVE_STATIC_FILES: "1"
|
||||
NO_PROXY: app,postgres,localhost,127.0.0.1
|
||||
no_proxy: app,postgres,localhost,127.0.0.1
|
||||
TURNIERE_E2E_BASE_URL: http://app:3000
|
||||
TURNIERE_E2E_EMAIL: e2e@example.com
|
||||
TURNIERE_E2E_PASSWORD: password123
|
||||
TURNIERE_E2E_USERNAME: e2e-user
|
||||
TURNIERE_LOAD_HTTP_CLIENTS: "1,10,50,100"
|
||||
TURNIERE_LOAD_WEBSOCKET_CLIENTS: "1,10,50,100,200"
|
||||
TURNIERE_LOAD_HTTP_REQUESTS_PER_CLIENT: "3"
|
||||
TURNIERE_LOAD_WEBSOCKET_HOLD_SECONDS: "1.0"
|
||||
TURNIERE_LOAD_WEBSOCKET_CONNECT_BATCH_SIZE: "25"
|
||||
rules:
|
||||
- if: $SKIP_TEST
|
||||
when: never
|
||||
- if: $SKIP_ALL_SPECS
|
||||
when: never
|
||||
- if: $SKIP_LOAD_SPEC
|
||||
when: never
|
||||
- if: $CI_PIPELINE_SOURCE != "push"
|
||||
when: never
|
||||
- when: always
|
||||
script:
|
||||
- cd /app
|
||||
- inv blackbox-load-service
|
||||
|
||||
rails follow sync e2e spec:
|
||||
stage: e2e
|
||||
tags:
|
||||
|
|
@ -134,47 +202,55 @@ rails follow sync e2e spec:
|
|||
- name: $CI_REGISTRY_IMAGE/production/commits:$CI_COMMIT_SHA
|
||||
alias: source-app
|
||||
variables:
|
||||
DATABASE_URL: postgres://turniere:turniere@source-postgres:5432/turniere_source_blackbox
|
||||
POSTGRES_URL: postgres://turniere:turniere@source-postgres:5432/turniere_source_blackbox
|
||||
POSTGRES_USERNAME: turniere
|
||||
POSTGRES_PASSWORD: turniere
|
||||
SECRET_KEY_BASE: turniere-blackbox-secret-key-base-please-change-in-real-prod
|
||||
MAILGUN_API_KEY: blackbox-test-api-key
|
||||
MAILGUN_DOMAIN: blackbox.example.com
|
||||
TURNIERE_DISABLE_EMAIL_DELIVERY: "1"
|
||||
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
|
||||
RAILS_LOG_TO_STDOUT: "1"
|
||||
RAILS_SERVE_STATIC_FILES: "1"
|
||||
WEB_CONCURRENCY: "1"
|
||||
RAILS_MAX_THREADS: "5"
|
||||
RAILS_DB_POOL: "5"
|
||||
SIDEKIQ_CONCURRENCY: "5"
|
||||
command:
|
||||
- bundle
|
||||
- exec
|
||||
- rails
|
||||
- s
|
||||
- -p
|
||||
- "3000"
|
||||
- -b
|
||||
- 0.0.0.0
|
||||
- name: $CI_REGISTRY_IMAGE/production/commits:$CI_COMMIT_SHA
|
||||
alias: follower-app
|
||||
variables:
|
||||
DATABASE_URL: postgres://turniere:turniere@follower-postgres:5432/turniere_follower_blackbox
|
||||
POSTGRES_URL: postgres://turniere:turniere@follower-postgres:5432/turniere_follower_blackbox
|
||||
POSTGRES_USERNAME: turniere
|
||||
POSTGRES_PASSWORD: turniere
|
||||
SECRET_KEY_BASE: turniere-blackbox-secret-key-base-please-change-in-real-prod
|
||||
MAILGUN_API_KEY: blackbox-test-api-key
|
||||
MAILGUN_DOMAIN: blackbox.example.com
|
||||
TURNIERE_DISABLE_EMAIL_DELIVERY: "1"
|
||||
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
|
||||
RAILS_LOG_TO_STDOUT: "1"
|
||||
RAILS_SERVE_STATIC_FILES: "1"
|
||||
WEB_CONCURRENCY: "1"
|
||||
RAILS_MAX_THREADS: "5"
|
||||
RAILS_DB_POOL: "5"
|
||||
SIDEKIQ_CONCURRENCY: "5"
|
||||
command:
|
||||
- bundle
|
||||
- exec
|
||||
- rails
|
||||
- s
|
||||
- -p
|
||||
- "3000"
|
||||
- -b
|
||||
- 0.0.0.0
|
||||
variables:
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
require:
|
||||
plugins:
|
||||
- rubocop-performance
|
||||
- rubocop-rails
|
||||
|
||||
|
|
@ -9,6 +9,7 @@ AllCops:
|
|||
Exclude:
|
||||
- "bin/**/*"
|
||||
- "db/schema.rb"
|
||||
- "spec/**/*.rb"
|
||||
|
||||
# Enable Rails
|
||||
Rails:
|
||||
|
|
@ -64,12 +65,10 @@ Rails/SkipsModelValidations:
|
|||
- "app/models/tournament_sync_queue_entry.rb"
|
||||
- "app/services/tournament_sync_pusher.rb"
|
||||
- "db/migrate/**/*"
|
||||
- "spec/controllers/tournaments_controller_spec.rb"
|
||||
|
||||
Style/MultilineBlockChain:
|
||||
Exclude:
|
||||
- "e2e/lib/api_client.rb"
|
||||
- "spec/e2e/http/tournament_follow_sync_spec.rb"
|
||||
|
||||
# Disable documentation checks for now
|
||||
# should be done with yard instead
|
||||
|
|
|
|||
|
|
@ -1 +1 @@
|
|||
3.1.2
|
||||
4.0.3
|
||||
|
|
|
|||
34
Dockerfile
34
Dockerfile
|
|
@ -1,11 +1,13 @@
|
|||
# syntax=docker/dockerfile:1.7
|
||||
|
||||
ARG RUBY_VERSION=3.1.2
|
||||
ARG BUNDLER_VERSION=2.3.13
|
||||
ARG RUBY_VERSION=4.0.3
|
||||
ARG BUNDLER_VERSION=4.0.6
|
||||
ARG base_commit=aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa
|
||||
|
||||
FROM ruby:${RUBY_VERSION}-slim AS runtime-base
|
||||
|
||||
ARG BUNDLER_VERSION
|
||||
ARG DEBIAN_FRONTEND=noninteractive
|
||||
|
||||
ENV LANG=C.UTF-8 \
|
||||
BUNDLE_JOBS=4 \
|
||||
|
|
@ -18,6 +20,7 @@ WORKDIR /app
|
|||
RUN apt-get update -qq \
|
||||
&& apt-get install -y --no-install-recommends \
|
||||
libpq5 \
|
||||
libyaml-0-2 \
|
||||
nodejs \
|
||||
openssl \
|
||||
&& rm -rf /var/lib/apt/lists/* \
|
||||
|
|
@ -26,15 +29,17 @@ RUN apt-get update -qq \
|
|||
|
||||
FROM runtime-base AS build-base
|
||||
|
||||
ARG DEBIAN_FRONTEND=noninteractive
|
||||
|
||||
RUN apt-get update -qq \
|
||||
&& apt-get install -y --no-install-recommends \
|
||||
build-essential \
|
||||
git \
|
||||
libpq-dev \
|
||||
libsqlite3-dev \
|
||||
libyaml-dev \
|
||||
pkg-config \
|
||||
python3 \
|
||||
python3-pip \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
|
||||
COPY Gemfile Gemfile.lock ./
|
||||
|
|
@ -54,13 +59,14 @@ RUN bundle config set with 'development test' \
|
|||
|
||||
FROM runtime-base AS production
|
||||
|
||||
ARG GIT_COMMIT_SHA=aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa
|
||||
ARG base_commit=aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa
|
||||
|
||||
ENV RAILS_ENV=production
|
||||
ENV GIT_COMMIT_SHA=${GIT_COMMIT_SHA}
|
||||
ENV RAILS_ENV=production \
|
||||
GIT_COMMIT_SHA=${base_commit}
|
||||
|
||||
COPY --from=bundle-production /usr/local/bundle /usr/local/bundle
|
||||
COPY --from=bundle-production /app/vendor/bundle /app/vendor/bundle
|
||||
|
||||
COPY Gemfile Gemfile.lock /app/
|
||||
COPY app /app/app
|
||||
COPY bin /app/bin
|
||||
|
|
@ -69,30 +75,32 @@ COPY db /app/db
|
|||
COPY public /app/public
|
||||
COPY script /app/script
|
||||
COPY config.ru Rakefile /app/
|
||||
|
||||
# Duplicate production environment to beta environment
|
||||
COPY config/environments/production.rb /app/config/environments/beta.rb
|
||||
|
||||
EXPOSE 3000
|
||||
|
||||
CMD ["sh", "-lc", "bundle exec rails db:migrate && bundle exec rails s -p 3000 -b 0.0.0.0"]
|
||||
CMD ["sh", "-lc", "bundle exec rails db:migrate && bundle exec rails s -b 0.0.0.0"]
|
||||
|
||||
FROM runtime-base AS test
|
||||
|
||||
ARG GIT_COMMIT_SHA=aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa
|
||||
ARG base_commit=aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa
|
||||
ARG DEBIAN_FRONTEND=noninteractive
|
||||
|
||||
ENV RAILS_ENV=test
|
||||
ENV GIT_COMMIT_SHA=${GIT_COMMIT_SHA}
|
||||
ENV RAILS_ENV=test \
|
||||
GIT_COMMIT_SHA=${base_commit}
|
||||
|
||||
RUN apt-get update -qq \
|
||||
&& apt-get install -y --no-install-recommends \
|
||||
libsqlite3-0 \
|
||||
python3 \
|
||||
python3-pip \
|
||||
&& rm -rf /var/lib/apt/lists/* \
|
||||
&& python3 -m pip install --no-cache-dir invoke
|
||||
python3-invoke \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
|
||||
COPY --from=bundle-test /usr/local/bundle /usr/local/bundle
|
||||
COPY --from=bundle-test /app/vendor/bundle /app/vendor/bundle
|
||||
|
||||
COPY Gemfile Gemfile.lock /app/
|
||||
COPY app /app/app
|
||||
COPY bin /app/bin
|
||||
|
|
|
|||
12
Gemfile
12
Gemfile
|
|
@ -3,12 +3,12 @@
|
|||
source 'https://rubygems.org'
|
||||
git_source(:github) { |repo| "https://github.com/#{repo}.git" }
|
||||
|
||||
ruby '3.1.2'
|
||||
ruby '4.0.3'
|
||||
|
||||
# Bundle edge Rails instead: gem 'rails', github: 'rails/rails'
|
||||
gem 'rails', '~> 7.0.3'
|
||||
gem 'rails', '~> 8.1'
|
||||
# Use Puma as the app server
|
||||
gem 'puma', '~> 5.0'
|
||||
gem 'puma', '~> 8.0'
|
||||
# Build JSON APIs with ease. Read more: https://github.com/rails/jbuilder
|
||||
# gem 'jbuilder', '~> 2.5'
|
||||
# Use ActiveModel has_secure_password
|
||||
|
|
@ -28,7 +28,7 @@ gem 'bootsnap', require: false
|
|||
|
||||
# Authentication
|
||||
gem 'devise'
|
||||
gem 'devise_token_auth', git: 'https://github.com/Thor77/devise_token_auth', branch: 'fix-api-only-mode'
|
||||
gem 'devise_token_auth'
|
||||
|
||||
gem 'rack-cors'
|
||||
|
||||
|
|
@ -58,7 +58,7 @@ group :development, optional: true do
|
|||
gem 'rubocop-rails'
|
||||
gem 'solargraph'
|
||||
gem 'spring'
|
||||
gem 'spring-watcher-listen', '~> 2.0.0'
|
||||
gem 'spring-watcher-listen', '~> 2.1'
|
||||
gem 'sqlite3'
|
||||
end
|
||||
|
||||
|
|
@ -67,4 +67,4 @@ group :production do
|
|||
end
|
||||
|
||||
# Windows does not include zoneinfo files, so bundle the tzinfo-data gem
|
||||
gem 'tzinfo-data', platforms: %i[mingw mswin x64_mingw jruby]
|
||||
gem 'tzinfo-data', platforms: %i[windows jruby]
|
||||
|
|
|
|||
521
Gemfile.lock
521
Gemfile.lock
|
|
@ -1,326 +1,407 @@
|
|||
GIT
|
||||
remote: https://github.com/Thor77/devise_token_auth
|
||||
revision: 30ba21add5c87b2964b909c6fc5cd18cfeae2191
|
||||
branch: fix-api-only-mode
|
||||
specs:
|
||||
devise_token_auth (1.2.0)
|
||||
bcrypt (~> 3.0)
|
||||
devise (> 3.5.2, < 5)
|
||||
rails (>= 4.2.0, < 7.1)
|
||||
|
||||
GEM
|
||||
remote: https://rubygems.org/
|
||||
specs:
|
||||
actioncable (7.0.8.1)
|
||||
actionpack (= 7.0.8.1)
|
||||
activesupport (= 7.0.8.1)
|
||||
action_text-trix (2.1.18)
|
||||
railties
|
||||
actioncable (8.1.3)
|
||||
actionpack (= 8.1.3)
|
||||
activesupport (= 8.1.3)
|
||||
nio4r (~> 2.0)
|
||||
websocket-driver (>= 0.6.1)
|
||||
actionmailbox (7.0.8.1)
|
||||
actionpack (= 7.0.8.1)
|
||||
activejob (= 7.0.8.1)
|
||||
activerecord (= 7.0.8.1)
|
||||
activestorage (= 7.0.8.1)
|
||||
activesupport (= 7.0.8.1)
|
||||
mail (>= 2.7.1)
|
||||
net-imap
|
||||
net-pop
|
||||
net-smtp
|
||||
actionmailer (7.0.8.1)
|
||||
actionpack (= 7.0.8.1)
|
||||
actionview (= 7.0.8.1)
|
||||
activejob (= 7.0.8.1)
|
||||
activesupport (= 7.0.8.1)
|
||||
mail (~> 2.5, >= 2.5.4)
|
||||
net-imap
|
||||
net-pop
|
||||
net-smtp
|
||||
rails-dom-testing (~> 2.0)
|
||||
actionpack (7.0.8.1)
|
||||
actionview (= 7.0.8.1)
|
||||
activesupport (= 7.0.8.1)
|
||||
rack (~> 2.0, >= 2.2.4)
|
||||
zeitwerk (~> 2.6)
|
||||
actionmailbox (8.1.3)
|
||||
actionpack (= 8.1.3)
|
||||
activejob (= 8.1.3)
|
||||
activerecord (= 8.1.3)
|
||||
activestorage (= 8.1.3)
|
||||
activesupport (= 8.1.3)
|
||||
mail (>= 2.8.0)
|
||||
actionmailer (8.1.3)
|
||||
actionpack (= 8.1.3)
|
||||
actionview (= 8.1.3)
|
||||
activejob (= 8.1.3)
|
||||
activesupport (= 8.1.3)
|
||||
mail (>= 2.8.0)
|
||||
rails-dom-testing (~> 2.2)
|
||||
actionpack (8.1.3)
|
||||
actionview (= 8.1.3)
|
||||
activesupport (= 8.1.3)
|
||||
nokogiri (>= 1.8.5)
|
||||
rack (>= 2.2.4)
|
||||
rack-session (>= 1.0.1)
|
||||
rack-test (>= 0.6.3)
|
||||
rails-dom-testing (~> 2.0)
|
||||
rails-html-sanitizer (~> 1.0, >= 1.2.0)
|
||||
actiontext (7.0.8.1)
|
||||
actionpack (= 7.0.8.1)
|
||||
activerecord (= 7.0.8.1)
|
||||
activestorage (= 7.0.8.1)
|
||||
activesupport (= 7.0.8.1)
|
||||
rails-dom-testing (~> 2.2)
|
||||
rails-html-sanitizer (~> 1.6)
|
||||
useragent (~> 0.16)
|
||||
actiontext (8.1.3)
|
||||
action_text-trix (~> 2.1.15)
|
||||
actionpack (= 8.1.3)
|
||||
activerecord (= 8.1.3)
|
||||
activestorage (= 8.1.3)
|
||||
activesupport (= 8.1.3)
|
||||
globalid (>= 0.6.0)
|
||||
nokogiri (>= 1.8.5)
|
||||
actionview (7.0.8.1)
|
||||
activesupport (= 7.0.8.1)
|
||||
actionview (8.1.3)
|
||||
activesupport (= 8.1.3)
|
||||
builder (~> 3.1)
|
||||
erubi (~> 1.4)
|
||||
rails-dom-testing (~> 2.0)
|
||||
rails-html-sanitizer (~> 1.1, >= 1.2.0)
|
||||
active_model_serializers (0.10.14)
|
||||
erubi (~> 1.11)
|
||||
rails-dom-testing (~> 2.2)
|
||||
rails-html-sanitizer (~> 1.6)
|
||||
active_model_serializers (0.10.16)
|
||||
actionpack (>= 4.1)
|
||||
activemodel (>= 4.1)
|
||||
case_transform (>= 0.2)
|
||||
jsonapi-renderer (>= 0.1.1.beta1, < 0.3)
|
||||
activejob (7.0.8.1)
|
||||
activesupport (= 7.0.8.1)
|
||||
activejob (8.1.3)
|
||||
activesupport (= 8.1.3)
|
||||
globalid (>= 0.3.6)
|
||||
activemodel (7.0.8.1)
|
||||
activesupport (= 7.0.8.1)
|
||||
activerecord (7.0.8.1)
|
||||
activemodel (= 7.0.8.1)
|
||||
activesupport (= 7.0.8.1)
|
||||
activestorage (7.0.8.1)
|
||||
actionpack (= 7.0.8.1)
|
||||
activejob (= 7.0.8.1)
|
||||
activerecord (= 7.0.8.1)
|
||||
activesupport (= 7.0.8.1)
|
||||
activemodel (8.1.3)
|
||||
activesupport (= 8.1.3)
|
||||
activerecord (8.1.3)
|
||||
activemodel (= 8.1.3)
|
||||
activesupport (= 8.1.3)
|
||||
timeout (>= 0.4.0)
|
||||
activestorage (8.1.3)
|
||||
actionpack (= 8.1.3)
|
||||
activejob (= 8.1.3)
|
||||
activerecord (= 8.1.3)
|
||||
activesupport (= 8.1.3)
|
||||
marcel (~> 1.0)
|
||||
mini_mime (>= 1.1.0)
|
||||
activesupport (7.0.8.1)
|
||||
concurrent-ruby (~> 1.0, >= 1.0.2)
|
||||
activesupport (8.1.3)
|
||||
base64
|
||||
bigdecimal
|
||||
concurrent-ruby (~> 1.0, >= 1.3.1)
|
||||
connection_pool (>= 2.2.5)
|
||||
drb
|
||||
i18n (>= 1.6, < 2)
|
||||
json
|
||||
logger (>= 1.4.2)
|
||||
minitest (>= 5.1)
|
||||
tzinfo (~> 2.0)
|
||||
ast (2.4.2)
|
||||
securerandom (>= 0.3)
|
||||
tzinfo (~> 2.0, >= 2.0.5)
|
||||
uri (>= 0.13.1)
|
||||
ast (2.4.3)
|
||||
backport (1.2.0)
|
||||
bcrypt (3.1.20)
|
||||
benchmark (0.3.0)
|
||||
bootsnap (1.18.3)
|
||||
base64 (0.3.0)
|
||||
bcrypt (3.1.22)
|
||||
benchmark (0.5.0)
|
||||
bigdecimal (4.1.2)
|
||||
bootsnap (1.24.0)
|
||||
msgpack (~> 1.2)
|
||||
builder (3.2.4)
|
||||
builder (3.3.0)
|
||||
case_transform (0.2)
|
||||
activesupport
|
||||
concurrent-ruby (1.2.3)
|
||||
concurrent-ruby (1.3.6)
|
||||
connection_pool (3.0.2)
|
||||
crass (1.0.6)
|
||||
date (3.3.4)
|
||||
devise (4.9.3)
|
||||
date (3.5.1)
|
||||
devise (4.9.4)
|
||||
bcrypt (~> 3.0)
|
||||
orm_adapter (~> 0.1)
|
||||
railties (>= 4.1.0)
|
||||
responders
|
||||
warden (~> 1.2.3)
|
||||
diff-lcs (1.5.1)
|
||||
domain_name (0.6.20240107)
|
||||
e2mmap (0.1.0)
|
||||
erubi (1.12.0)
|
||||
factory_bot (6.4.6)
|
||||
activesupport (>= 5.0.0)
|
||||
factory_bot_rails (6.4.3)
|
||||
factory_bot (~> 6.4)
|
||||
railties (>= 5.0.0)
|
||||
faker (3.3.1)
|
||||
devise_token_auth (1.2.6)
|
||||
bcrypt (~> 3.0)
|
||||
devise (> 3.5.2, < 5)
|
||||
rails (>= 4.2.0, < 8.2)
|
||||
diff-lcs (1.6.2)
|
||||
drb (2.2.3)
|
||||
erb (6.0.4)
|
||||
erubi (1.13.1)
|
||||
factory_bot (6.5.6)
|
||||
activesupport (>= 6.1.0)
|
||||
factory_bot_rails (6.5.1)
|
||||
factory_bot (~> 6.5)
|
||||
railties (>= 6.1.0)
|
||||
faker (3.8.0)
|
||||
i18n (>= 1.8.11, < 2)
|
||||
ffi (1.16.3)
|
||||
globalid (1.2.1)
|
||||
faraday (2.14.1)
|
||||
faraday-net_http (>= 2.0, < 3.5)
|
||||
json
|
||||
logger
|
||||
faraday-multipart (1.2.0)
|
||||
multipart-post (~> 2.0)
|
||||
faraday-net_http (3.4.2)
|
||||
net-http (~> 0.5)
|
||||
ffi (1.17.4-aarch64-linux-gnu)
|
||||
ffi (1.17.4-aarch64-linux-musl)
|
||||
ffi (1.17.4-arm64-darwin)
|
||||
ffi (1.17.4-x86_64-linux-gnu)
|
||||
globalid (1.3.0)
|
||||
activesupport (>= 6.1)
|
||||
http-accept (1.7.0)
|
||||
http-cookie (1.0.5)
|
||||
domain_name (~> 0.5)
|
||||
i18n (1.14.4)
|
||||
i18n (1.14.8)
|
||||
concurrent-ruby (~> 1.0)
|
||||
interactor (3.1.2)
|
||||
interactor-rails (2.2.1)
|
||||
interactor (3.2.0)
|
||||
ostruct
|
||||
interactor-rails (2.3.0)
|
||||
interactor (~> 3.0)
|
||||
rails (>= 4.2)
|
||||
jaro_winkler (1.5.6)
|
||||
json (2.7.2)
|
||||
railties (>= 7.0)
|
||||
io-console (0.8.2)
|
||||
irb (1.18.0)
|
||||
pp (>= 0.6.0)
|
||||
prism (>= 1.3.0)
|
||||
rdoc (>= 4.0.0)
|
||||
reline (>= 0.4.2)
|
||||
jaro_winkler (1.7.0)
|
||||
json (2.19.4)
|
||||
jsonapi-renderer (0.2.2)
|
||||
kramdown (2.4.0)
|
||||
rexml
|
||||
kramdown (2.5.2)
|
||||
rexml (>= 3.4.4)
|
||||
kramdown-parser-gfm (1.1.0)
|
||||
kramdown (~> 2.0)
|
||||
language_server-protocol (3.17.0.3)
|
||||
listen (3.9.0)
|
||||
language_server-protocol (3.17.0.5)
|
||||
lint_roller (1.1.0)
|
||||
listen (3.10.0)
|
||||
logger
|
||||
rb-fsevent (~> 0.10, >= 0.10.3)
|
||||
rb-inotify (~> 0.9, >= 0.9.10)
|
||||
loofah (2.22.0)
|
||||
logger (1.7.0)
|
||||
loofah (2.25.1)
|
||||
crass (~> 1.0.2)
|
||||
nokogiri (>= 1.12.0)
|
||||
mail (2.8.1)
|
||||
mail (2.9.0)
|
||||
logger
|
||||
mini_mime (>= 0.1.1)
|
||||
net-imap
|
||||
net-pop
|
||||
net-smtp
|
||||
mailgun-ruby (1.2.14)
|
||||
rest-client (>= 2.0.2)
|
||||
marcel (1.0.4)
|
||||
method_source (1.0.0)
|
||||
mime-types (3.5.2)
|
||||
mime-types-data (~> 3.2015)
|
||||
mime-types-data (3.2024.0305)
|
||||
mailgun-ruby (1.4.3)
|
||||
faraday (~> 2.1)
|
||||
faraday-multipart (< 2)
|
||||
mini_mime
|
||||
zeitwerk
|
||||
marcel (1.1.0)
|
||||
mini_mime (1.1.5)
|
||||
minitest (5.22.3)
|
||||
msgpack (1.7.2)
|
||||
net-imap (0.4.10)
|
||||
minitest (6.0.5)
|
||||
drb (~> 2.0)
|
||||
prism (~> 1.5)
|
||||
msgpack (1.8.0)
|
||||
multipart-post (2.4.1)
|
||||
net-http (0.9.1)
|
||||
uri (>= 0.11.1)
|
||||
net-imap (0.6.4)
|
||||
date
|
||||
net-protocol
|
||||
net-pop (0.1.2)
|
||||
net-protocol
|
||||
net-protocol (0.2.2)
|
||||
timeout
|
||||
net-smtp (0.5.0)
|
||||
net-smtp (0.5.1)
|
||||
net-protocol
|
||||
netrc (0.11.0)
|
||||
nio4r (2.7.1)
|
||||
nokogiri (1.16.3-aarch64-linux)
|
||||
nio4r (2.7.5)
|
||||
nokogiri (1.19.3-aarch64-linux-gnu)
|
||||
racc (~> 1.4)
|
||||
nokogiri (1.16.3-arm64-darwin)
|
||||
nokogiri (1.19.3-aarch64-linux-musl)
|
||||
racc (~> 1.4)
|
||||
nokogiri (1.16.3-x86_64-linux)
|
||||
nokogiri (1.19.3-arm64-darwin)
|
||||
racc (~> 1.4)
|
||||
nokogiri (1.19.3-x86_64-linux-gnu)
|
||||
racc (~> 1.4)
|
||||
observer (0.1.2)
|
||||
open3 (0.2.1)
|
||||
orm_adapter (0.5.0)
|
||||
parallel (1.24.0)
|
||||
parser (3.3.0.5)
|
||||
ostruct (0.6.3)
|
||||
parallel (2.1.0)
|
||||
parser (3.3.11.1)
|
||||
ast (~> 2.4.1)
|
||||
racc
|
||||
pg (1.5.6)
|
||||
puma (5.6.8)
|
||||
pg (1.6.3-aarch64-linux)
|
||||
pg (1.6.3-aarch64-linux-musl)
|
||||
pg (1.6.3-arm64-darwin)
|
||||
pg (1.6.3-x86_64-linux)
|
||||
pp (0.6.3)
|
||||
prettyprint
|
||||
prettyprint (0.2.0)
|
||||
prism (1.9.0)
|
||||
psych (5.3.1)
|
||||
date
|
||||
stringio
|
||||
puma (8.0.1)
|
||||
nio4r (~> 2.0)
|
||||
racc (1.7.3)
|
||||
rack (2.2.9)
|
||||
rack-cors (2.0.2)
|
||||
rack (>= 2.0.0)
|
||||
rack-test (2.1.0)
|
||||
racc (1.8.1)
|
||||
rack (3.2.6)
|
||||
rack-cors (3.0.0)
|
||||
logger
|
||||
rack (>= 3.0.14)
|
||||
rack-session (2.1.2)
|
||||
base64 (>= 0.1.0)
|
||||
rack (>= 3.0.0)
|
||||
rack-test (2.2.0)
|
||||
rack (>= 1.3)
|
||||
rackup (2.3.1)
|
||||
rack (>= 3)
|
||||
railroady (1.6.0)
|
||||
rails (7.0.8.1)
|
||||
actioncable (= 7.0.8.1)
|
||||
actionmailbox (= 7.0.8.1)
|
||||
actionmailer (= 7.0.8.1)
|
||||
actionpack (= 7.0.8.1)
|
||||
actiontext (= 7.0.8.1)
|
||||
actionview (= 7.0.8.1)
|
||||
activejob (= 7.0.8.1)
|
||||
activemodel (= 7.0.8.1)
|
||||
activerecord (= 7.0.8.1)
|
||||
activestorage (= 7.0.8.1)
|
||||
activesupport (= 7.0.8.1)
|
||||
rails (8.1.3)
|
||||
actioncable (= 8.1.3)
|
||||
actionmailbox (= 8.1.3)
|
||||
actionmailer (= 8.1.3)
|
||||
actionpack (= 8.1.3)
|
||||
actiontext (= 8.1.3)
|
||||
actionview (= 8.1.3)
|
||||
activejob (= 8.1.3)
|
||||
activemodel (= 8.1.3)
|
||||
activerecord (= 8.1.3)
|
||||
activestorage (= 8.1.3)
|
||||
activesupport (= 8.1.3)
|
||||
bundler (>= 1.15.0)
|
||||
railties (= 7.0.8.1)
|
||||
rails-dom-testing (2.2.0)
|
||||
railties (= 8.1.3)
|
||||
rails-dom-testing (2.3.0)
|
||||
activesupport (>= 5.0.0)
|
||||
minitest
|
||||
nokogiri (>= 1.6)
|
||||
rails-html-sanitizer (1.6.0)
|
||||
loofah (~> 2.21)
|
||||
nokogiri (~> 1.14)
|
||||
railties (7.0.8.1)
|
||||
actionpack (= 7.0.8.1)
|
||||
activesupport (= 7.0.8.1)
|
||||
method_source
|
||||
rails-html-sanitizer (1.7.0)
|
||||
loofah (~> 2.25)
|
||||
nokogiri (>= 1.15.7, != 1.16.7, != 1.16.6, != 1.16.5, != 1.16.4, != 1.16.3, != 1.16.2, != 1.16.1, != 1.16.0.rc1, != 1.16.0)
|
||||
railties (8.1.3)
|
||||
actionpack (= 8.1.3)
|
||||
activesupport (= 8.1.3)
|
||||
irb (~> 1.13)
|
||||
rackup (>= 1.0.0)
|
||||
rake (>= 12.2)
|
||||
thor (~> 1.0)
|
||||
zeitwerk (~> 2.5)
|
||||
thor (~> 1.0, >= 1.2.2)
|
||||
tsort (>= 0.2)
|
||||
zeitwerk (~> 2.6)
|
||||
rainbow (3.1.1)
|
||||
rake (13.2.1)
|
||||
rake (13.4.2)
|
||||
rb-fsevent (0.11.2)
|
||||
rb-inotify (0.10.1)
|
||||
rb-inotify (0.11.1)
|
||||
ffi (~> 1.0)
|
||||
rbs (2.8.4)
|
||||
regexp_parser (2.9.0)
|
||||
responders (3.1.1)
|
||||
actionpack (>= 5.2)
|
||||
railties (>= 5.2)
|
||||
rest-client (2.1.0)
|
||||
http-accept (>= 1.7.0, < 2.0)
|
||||
http-cookie (>= 1.0.2, < 2.0)
|
||||
mime-types (>= 1.16, < 4.0)
|
||||
netrc (~> 0.8)
|
||||
reverse_markdown (2.1.1)
|
||||
rbs (3.10.4)
|
||||
logger
|
||||
tsort
|
||||
rdoc (7.2.0)
|
||||
erb
|
||||
psych (>= 4.0.0)
|
||||
tsort
|
||||
regexp_parser (2.12.0)
|
||||
reline (0.6.3)
|
||||
io-console (~> 0.5)
|
||||
responders (3.2.0)
|
||||
actionpack (>= 7.0)
|
||||
railties (>= 7.0)
|
||||
reverse_markdown (3.0.2)
|
||||
nokogiri
|
||||
rexml (3.2.6)
|
||||
rspec-core (3.13.0)
|
||||
rexml (3.4.4)
|
||||
rspec-core (3.13.6)
|
||||
rspec-support (~> 3.13.0)
|
||||
rspec-expectations (3.13.0)
|
||||
rspec-expectations (3.13.5)
|
||||
diff-lcs (>= 1.2.0, < 2.0)
|
||||
rspec-support (~> 3.13.0)
|
||||
rspec-mocks (3.13.0)
|
||||
rspec-mocks (3.13.8)
|
||||
diff-lcs (>= 1.2.0, < 2.0)
|
||||
rspec-support (~> 3.13.0)
|
||||
rspec-rails (6.1.2)
|
||||
actionpack (>= 6.1)
|
||||
activesupport (>= 6.1)
|
||||
railties (>= 6.1)
|
||||
rspec-core (~> 3.13)
|
||||
rspec-expectations (~> 3.13)
|
||||
rspec-mocks (~> 3.13)
|
||||
rspec-support (~> 3.13)
|
||||
rspec-support (3.13.1)
|
||||
rubocop (1.62.1)
|
||||
rspec-rails (8.0.4)
|
||||
actionpack (>= 7.2)
|
||||
activesupport (>= 7.2)
|
||||
railties (>= 7.2)
|
||||
rspec-core (>= 3.13.0, < 5.0.0)
|
||||
rspec-expectations (>= 3.13.0, < 5.0.0)
|
||||
rspec-mocks (>= 3.13.0, < 5.0.0)
|
||||
rspec-support (>= 3.13.0, < 5.0.0)
|
||||
rspec-support (3.13.7)
|
||||
rubocop (1.86.1)
|
||||
json (~> 2.3)
|
||||
language_server-protocol (>= 3.17.0)
|
||||
parallel (~> 1.10)
|
||||
language_server-protocol (~> 3.17.0.2)
|
||||
lint_roller (~> 1.1.0)
|
||||
parallel (>= 1.10)
|
||||
parser (>= 3.3.0.2)
|
||||
rainbow (>= 2.2.2, < 4.0)
|
||||
regexp_parser (>= 1.8, < 3.0)
|
||||
rexml (>= 3.2.5, < 4.0)
|
||||
rubocop-ast (>= 1.31.1, < 2.0)
|
||||
regexp_parser (>= 2.9.3, < 3.0)
|
||||
rubocop-ast (>= 1.49.0, < 2.0)
|
||||
ruby-progressbar (~> 1.7)
|
||||
unicode-display_width (>= 2.4.0, < 3.0)
|
||||
rubocop-ast (1.31.2)
|
||||
parser (>= 3.3.0.4)
|
||||
rubocop-performance (1.21.0)
|
||||
rubocop (>= 1.48.1, < 2.0)
|
||||
rubocop-ast (>= 1.31.1, < 2.0)
|
||||
rubocop-rails (2.24.1)
|
||||
unicode-display_width (>= 2.4.0, < 4.0)
|
||||
rubocop-ast (1.49.1)
|
||||
parser (>= 3.3.7.2)
|
||||
prism (~> 1.7)
|
||||
rubocop-performance (1.26.1)
|
||||
lint_roller (~> 1.1)
|
||||
rubocop (>= 1.75.0, < 2.0)
|
||||
rubocop-ast (>= 1.47.1, < 2.0)
|
||||
rubocop-rails (2.34.3)
|
||||
activesupport (>= 4.2.0)
|
||||
lint_roller (~> 1.1)
|
||||
rack (>= 1.1)
|
||||
rubocop (>= 1.33.0, < 2.0)
|
||||
rubocop-ast (>= 1.31.1, < 2.0)
|
||||
ruby-prof (1.7.1)
|
||||
rubocop (>= 1.75.0, < 2.0)
|
||||
rubocop-ast (>= 1.44.0, < 2.0)
|
||||
ruby-prof (2.0.4)
|
||||
base64
|
||||
ostruct
|
||||
ruby-progressbar (1.13.0)
|
||||
shoulda-matchers (6.2.0)
|
||||
activesupport (>= 5.2.0)
|
||||
solargraph (0.50.0)
|
||||
securerandom (0.4.1)
|
||||
shoulda-matchers (7.0.1)
|
||||
activesupport (>= 7.1)
|
||||
solargraph (0.58.3)
|
||||
ast (~> 2.4.3)
|
||||
backport (~> 1.2)
|
||||
benchmark
|
||||
bundler (~> 2.0)
|
||||
benchmark (~> 0.4)
|
||||
bundler (>= 2.0)
|
||||
diff-lcs (~> 1.4)
|
||||
e2mmap
|
||||
jaro_winkler (~> 1.5)
|
||||
jaro_winkler (~> 1.6, >= 1.6.1)
|
||||
kramdown (~> 2.3)
|
||||
kramdown-parser-gfm (~> 1.1)
|
||||
logger (~> 1.6)
|
||||
observer (~> 0.1)
|
||||
open3 (~> 0.2.1)
|
||||
ostruct (~> 0.6)
|
||||
parser (~> 3.0)
|
||||
rbs (~> 2.0)
|
||||
reverse_markdown (~> 2.0)
|
||||
rubocop (~> 1.38)
|
||||
prism (~> 1.4)
|
||||
rbs (>= 3.6.1, <= 4.0.0.dev.4)
|
||||
reverse_markdown (~> 3.0)
|
||||
rubocop (~> 1.76)
|
||||
thor (~> 1.0)
|
||||
tilt (~> 2.0)
|
||||
yard (~> 0.9, >= 0.9.24)
|
||||
spring (2.1.1)
|
||||
spring-watcher-listen (2.0.1)
|
||||
yard-activesupport-concern (~> 0.0)
|
||||
yard-solargraph (~> 0.1)
|
||||
spring (4.4.2)
|
||||
spring-watcher-listen (2.1.0)
|
||||
listen (>= 2.7, < 4.0)
|
||||
spring (>= 1.2, < 3.0)
|
||||
sqlite3 (1.7.3-aarch64-linux)
|
||||
sqlite3 (1.7.3-arm64-darwin)
|
||||
sqlite3 (1.7.3-x86_64-linux)
|
||||
thor (1.3.1)
|
||||
tilt (2.3.0)
|
||||
timeout (0.4.1)
|
||||
spring (>= 4)
|
||||
sqlite3 (2.9.3-aarch64-linux-gnu)
|
||||
sqlite3 (2.9.3-aarch64-linux-musl)
|
||||
sqlite3 (2.9.3-arm64-darwin)
|
||||
sqlite3 (2.9.3-x86_64-linux-gnu)
|
||||
stringio (3.2.0)
|
||||
thor (1.5.0)
|
||||
tilt (2.7.0)
|
||||
timeout (0.6.1)
|
||||
tsort (0.2.0)
|
||||
tzinfo (2.0.6)
|
||||
concurrent-ruby (~> 1.0)
|
||||
unicode-display_width (2.5.0)
|
||||
unicode-display_width (3.2.0)
|
||||
unicode-emoji (~> 4.1)
|
||||
unicode-emoji (4.2.0)
|
||||
uri (1.1.1)
|
||||
useragent (0.16.11)
|
||||
warden (1.2.9)
|
||||
rack (>= 2.0.9)
|
||||
websocket-driver (0.7.6)
|
||||
websocket-driver (0.8.0)
|
||||
base64
|
||||
websocket-extensions (>= 0.1.0)
|
||||
websocket-extensions (0.1.5)
|
||||
yard (0.9.36)
|
||||
zeitwerk (2.6.13)
|
||||
yard (0.9.43)
|
||||
yard-activesupport-concern (0.0.1)
|
||||
yard (>= 0.8)
|
||||
yard-solargraph (0.1.0)
|
||||
yard (~> 0.9)
|
||||
zeitwerk (2.7.5)
|
||||
|
||||
PLATFORMS
|
||||
aarch64-linux
|
||||
aarch64-linux-musl
|
||||
arm64-darwin-22
|
||||
arm64-darwin-23
|
||||
arm64-darwin-24
|
||||
arm64-darwin-25
|
||||
x86_64-linux
|
||||
x86_64-linux-gnu
|
||||
|
||||
DEPENDENCIES
|
||||
active_model_serializers
|
||||
bootsnap
|
||||
devise
|
||||
devise_token_auth!
|
||||
devise_token_auth
|
||||
factory_bot_rails
|
||||
faker
|
||||
interactor
|
||||
|
|
@ -328,10 +409,10 @@ DEPENDENCIES
|
|||
listen (~> 3.3)
|
||||
mailgun-ruby
|
||||
pg
|
||||
puma (~> 5.0)
|
||||
puma (~> 8.0)
|
||||
rack-cors
|
||||
railroady
|
||||
rails (~> 7.0.3)
|
||||
rails (~> 8.1)
|
||||
rspec-rails
|
||||
rubocop
|
||||
rubocop-performance
|
||||
|
|
@ -340,12 +421,12 @@ DEPENDENCIES
|
|||
shoulda-matchers
|
||||
solargraph
|
||||
spring
|
||||
spring-watcher-listen (~> 2.0.0)
|
||||
spring-watcher-listen (~> 2.1)
|
||||
sqlite3
|
||||
tzinfo-data
|
||||
|
||||
RUBY VERSION
|
||||
ruby 3.1.2p20
|
||||
ruby 4.0.3
|
||||
|
||||
BUNDLED WITH
|
||||
2.3.7
|
||||
4.0.6
|
||||
|
|
|
|||
|
|
@ -30,6 +30,10 @@ bundle exec rspec
|
|||
Leader/follower tournament sync docs:
|
||||
|
||||
- [doc/leader_follower.md](doc/leader_follower.md)
|
||||
- [doc/account_profiles_api.md](doc/account_profiles_api.md)
|
||||
- [doc/tournament_management_api.md](doc/tournament_management_api.md)
|
||||
- [doc/match_metadata_api.md](doc/match_metadata_api.md)
|
||||
- [doc/team_action_lists_api.md](doc/team_action_lists_api.md)
|
||||
- [doc/timer_api.md](doc/timer_api.md)
|
||||
- [doc/beamer_live_updates.md](doc/beamer_live_updates.md)
|
||||
- [doc/tournament_live_updates.md](doc/tournament_live_updates.md)
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -0,0 +1,15 @@
|
|||
# frozen_string_literal: true
|
||||
|
||||
class AccountsController < ApplicationController
|
||||
before_action :set_account, only: %i[show]
|
||||
|
||||
def show
|
||||
render json: @account, serializer: AccountProfileSerializer, scope: current_user
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def set_account
|
||||
@account = User.includes(:tournaments, player_teams: :tournament).find(params[:id])
|
||||
end
|
||||
end
|
||||
|
|
@ -3,11 +3,14 @@
|
|||
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|
|
||||
render json: { error: e.message }, status: :bad_request
|
||||
end
|
||||
rescue_from ActiveRecord::RecordInvalid, with: :render_record_invalid_error
|
||||
rescue_from ActiveRecord::RecordNotFound, with: :render_not_found_error
|
||||
|
||||
protected
|
||||
|
||||
|
|
@ -18,6 +21,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
|
||||
|
|
@ -37,10 +57,19 @@ class ApplicationController < ActionController::API
|
|||
end
|
||||
|
||||
def require_writable_tournament!(tournament)
|
||||
render_read_only_error if tournament.read_only_mode?
|
||||
render_read_only_error if tournament&.read_only_mode?
|
||||
end
|
||||
|
||||
def render_not_found_error(exception)
|
||||
render json: { error: exception.to_s }, status: :not_found
|
||||
error = if exception.is_a?(ActiveRecord::RecordNotFound) && exception.model.present?
|
||||
"Couldn't find #{exception.model} with '#{exception.primary_key}'=#{exception.id.inspect}"
|
||||
else
|
||||
'Record not found'
|
||||
end
|
||||
render json: { error: error }, status: :not_found
|
||||
end
|
||||
|
||||
def render_record_invalid_error(exception)
|
||||
render json: exception.record.errors, status: :unprocessable_content
|
||||
end
|
||||
end
|
||||
|
|
|
|||
|
|
@ -36,7 +36,7 @@ class BeamersController < ApplicationController
|
|||
push_sync_if_needed!
|
||||
render json: beamer, status: :created
|
||||
else
|
||||
render json: beamer.errors, status: :unprocessable_entity
|
||||
render json: beamer.errors, status: :unprocessable_content
|
||||
end
|
||||
end
|
||||
|
||||
|
|
@ -46,7 +46,7 @@ class BeamersController < ApplicationController
|
|||
push_sync_if_needed!
|
||||
render json: @beamer
|
||||
else
|
||||
render json: @beamer.errors, status: :unprocessable_entity
|
||||
render json: @beamer.errors, status: :unprocessable_content
|
||||
end
|
||||
end
|
||||
|
||||
|
|
|
|||
|
|
@ -25,6 +25,6 @@ class BetsController < ApplicationController
|
|||
end
|
||||
|
||||
def handle_user_service_error(exception)
|
||||
render json: { error: exception.message }, status: :unprocessable_entity
|
||||
render json: { error: exception.message }, status: :unprocessable_content
|
||||
end
|
||||
end
|
||||
|
|
|
|||
|
|
@ -13,16 +13,40 @@ class MatchScoresController < ApplicationController
|
|||
|
||||
# PATCH/PUT /scores/1
|
||||
def update
|
||||
synced_tournament = nil
|
||||
|
||||
MatchScore.transaction do
|
||||
tournament = @match_score.tournament
|
||||
TournamentTransactionLogger.lock_tournament!(tournament)
|
||||
@match_score.reload
|
||||
old_values = score_values(@match_score)
|
||||
|
||||
if @match_score.update(match_score_params)
|
||||
UpdateGroupsGroupScoresAndSave.call(group: @match_score.match.group) if @match_score.part_of_group_match?
|
||||
push_sync_if_needed!(@match_score.tournament)
|
||||
TournamentLiveBroadcast.call(@match_score.tournament.id)
|
||||
group_scores = []
|
||||
if @match_score.part_of_group_match?
|
||||
UpdateGroupsGroupScoresAndSave.call(group: @match_score.match.group)
|
||||
group_scores = group_scores_for_log(@match_score.match.group)
|
||||
end
|
||||
TournamentTransactionLogger.record_match_score_update!(
|
||||
match_score: @match_score,
|
||||
user: current_user,
|
||||
changes: score_changes(old_values, @match_score),
|
||||
group_scores: group_scores
|
||||
)
|
||||
synced_tournament = tournament
|
||||
render json: @match_score
|
||||
else
|
||||
render json: @match_score.errors, status: :unprocessable_entity
|
||||
render json: @match_score.errors, status: :unprocessable_content
|
||||
raise ActiveRecord::Rollback
|
||||
end
|
||||
end
|
||||
|
||||
return if synced_tournament.nil?
|
||||
|
||||
push_sync_if_needed!(synced_tournament)
|
||||
TournamentLiveBroadcast.call(synced_tournament.id)
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
# Use callbacks to share common setup or constraints between actions.
|
||||
|
|
@ -35,6 +59,30 @@ class MatchScoresController < ApplicationController
|
|||
params.slice(:points, :hidden_points).permit!
|
||||
end
|
||||
|
||||
def score_values(match_score)
|
||||
{
|
||||
points: match_score.points,
|
||||
hidden_points: match_score.hidden_points
|
||||
}
|
||||
end
|
||||
|
||||
def score_changes(old_values, match_score)
|
||||
permitted_params = match_score_params
|
||||
score_values(match_score).each_with_object({}) do |(field, new_value), changes|
|
||||
next unless permitted_params.key?(field) || permitted_params.key?(field.to_s)
|
||||
next if old_values[field] == new_value
|
||||
|
||||
changes[field] = {
|
||||
from: old_values[field],
|
||||
to: new_value
|
||||
}
|
||||
end
|
||||
end
|
||||
|
||||
def group_scores_for_log(group)
|
||||
group.group_scores.includes(:team).reload
|
||||
end
|
||||
|
||||
def push_sync_if_needed!(tournament)
|
||||
TournamentSyncEnqueue.call(tournament)
|
||||
end
|
||||
|
|
|
|||
|
|
@ -7,9 +7,11 @@ class MatchesController < ApplicationController
|
|||
before_action :set_swap_target, only: %i[swap]
|
||||
before_action -> { require_owner! @match.owner }, only: %i[update swap]
|
||||
before_action -> { require_owner! @group.owner }, only: %i[create]
|
||||
before_action :set_tournament, only: %i[index]
|
||||
before_action :set_tournament, only: %i[index metadata]
|
||||
before_action -> { require_owner! @tournament.owner }, only: %i[metadata]
|
||||
before_action -> { require_writable_tournament!(@match.tournament) }, only: %i[update swap]
|
||||
before_action -> { require_writable_tournament!(@group.tournament) }, only: %i[create]
|
||||
before_action -> { require_writable_tournament!(@tournament) }, only: %i[metadata]
|
||||
|
||||
# GET/tournaments/1/matches
|
||||
def index
|
||||
|
|
@ -23,49 +25,74 @@ class MatchesController < ApplicationController
|
|||
|
||||
# POST /groups/:group_id/matches
|
||||
def create
|
||||
match = nil
|
||||
synced_tournament = nil
|
||||
|
||||
Match.transaction do
|
||||
TournamentTransactionLogger.lock_tournament!(@group.tournament)
|
||||
match = build_decider_match
|
||||
|
||||
if match.errors.any?
|
||||
render json: match.errors, status: :unprocessable_entity
|
||||
render json: match.errors, status: :unprocessable_content
|
||||
raise ActiveRecord::Rollback
|
||||
elsif match.save
|
||||
UpdateGroupsGroupScoresAndSave.call(group: @group)
|
||||
push_sync_if_needed!(@group.tournament)
|
||||
TournamentLiveBroadcast.call(@group.tournament.id)
|
||||
group_scores = group_scores_for_log(@group)
|
||||
TournamentTransactionLogger.record_decider_match_created!(match: match, user: current_user,
|
||||
group_scores: group_scores)
|
||||
synced_tournament = @group.tournament
|
||||
render json: match, include: %w[match_scores.team group], status: :created
|
||||
else
|
||||
render json: match.errors, status: :unprocessable_entity
|
||||
render json: match.errors, status: :unprocessable_content
|
||||
raise ActiveRecord::Rollback
|
||||
end
|
||||
end
|
||||
|
||||
push_sync_if_needed!(synced_tournament) unless synced_tournament.nil?
|
||||
TournamentLiveBroadcast.call(synced_tournament.id) unless synced_tournament.nil?
|
||||
end
|
||||
|
||||
# PATCH/PUT /matches/1
|
||||
def update
|
||||
new_state = match_params['state']
|
||||
synced_tournament = nil
|
||||
@transaction_log_group_scores = []
|
||||
@transaction_log_advanced_match_ids = []
|
||||
|
||||
Match.transaction do
|
||||
if @match.update(match_params)
|
||||
handle_match_end if new_state == 'finished'
|
||||
if @match.group_match? && new_state == 'in_progress'
|
||||
group = @match.group
|
||||
logger.warn "Updating groups group score failed for #{group}" unless UpdateGroupsGroupScoresAndSave.call(group:).success?
|
||||
end
|
||||
|
||||
render json: @match
|
||||
synced_tournament = @match.tournament
|
||||
else
|
||||
render json: @match.errors, status: :unprocessable_entity
|
||||
raise ActiveRecord::Rollback
|
||||
end
|
||||
synced_tournament = update_match_inside_transaction(new_state)
|
||||
end
|
||||
push_sync_if_needed!(synced_tournament) unless synced_tournament.nil?
|
||||
TournamentLiveBroadcast.call(synced_tournament.id) unless synced_tournament.nil?
|
||||
end
|
||||
|
||||
# PATCH /tournaments/1/matches/metadata
|
||||
def metadata
|
||||
attrs = match_metadata_params
|
||||
if attrs.empty?
|
||||
render json: { error: 'At least one metadata field is required' }, status: :bad_request
|
||||
return
|
||||
end
|
||||
|
||||
matches = bulk_metadata_matches
|
||||
return if performed?
|
||||
|
||||
Match.transaction do
|
||||
TournamentTransactionLogger.lock_tournament!(@tournament)
|
||||
@tournament.reload
|
||||
matches.each { |match| match.update!(attrs) }
|
||||
end
|
||||
|
||||
render json: matches.map(&:reload), each_serializer: LiveMatchSerializer, include: %w[match_scores.team]
|
||||
push_sync_if_needed!(@tournament)
|
||||
TournamentLiveBroadcast.call(@tournament.id)
|
||||
end
|
||||
|
||||
# PATCH /matches/1/swap
|
||||
def swap
|
||||
unless @match.swappable_with?(@swap_target)
|
||||
render json: { error: 'Matches must be different group matches from the same group' },
|
||||
status: :unprocessable_entity
|
||||
status: :unprocessable_content
|
||||
return
|
||||
end
|
||||
|
||||
|
|
@ -93,33 +120,94 @@ class MatchesController < ApplicationController
|
|||
def handle_match_end
|
||||
if @match.group_match?
|
||||
group = @match.group
|
||||
logger.warn "Updating groups group score failed for #{group}" unless UpdateGroupsGroupScoresAndSave.call(group:).success?
|
||||
logger.warn "Updating groups group score failed for #{group}" unless UpdateGroupsGroupScoresAndSave.call(group: group).success?
|
||||
@transaction_log_group_scores = group_scores_for_log(group)
|
||||
return
|
||||
end
|
||||
|
||||
return if @match.group_match?
|
||||
|
||||
if @match.winner.nil?
|
||||
render json: { error: 'Stopping undecided Matches isn\'t allowed in playoff stage' },
|
||||
status: :unprocessable_entity
|
||||
status: :unprocessable_content
|
||||
raise ActiveRecord::Rollback
|
||||
end
|
||||
|
||||
return if PopulateMatchBelowAndSave.call(match: @match).success?
|
||||
result = PopulateMatchBelowAndSave.call(match: @match)
|
||||
if result.success?
|
||||
@transaction_log_advanced_match_ids = advanced_match_ids_for(result)
|
||||
return
|
||||
end
|
||||
|
||||
render json: { error: 'Moving Team one stage down failed' },
|
||||
status: :unprocessable_entity
|
||||
status: :unprocessable_content
|
||||
raise ActiveRecord::Rollback
|
||||
end
|
||||
|
||||
def handle_match_stop
|
||||
@match.match_scores.each do |match_score|
|
||||
match_score.update!(points: 0, hidden_points: 0)
|
||||
end
|
||||
end
|
||||
|
||||
def update_group_scores
|
||||
group = @match.group
|
||||
if UpdateGroupsGroupScoresAndSave.call(group: group).success?
|
||||
@transaction_log_group_scores = group_scores_for_log(group)
|
||||
return
|
||||
end
|
||||
|
||||
logger.warn "Updating groups group score failed for #{group}"
|
||||
end
|
||||
|
||||
def update_match_inside_transaction(new_state)
|
||||
tournament = @match.tournament
|
||||
TournamentTransactionLogger.lock_tournament!(tournament)
|
||||
@match.reload
|
||||
validate_transition_inside_transaction(new_state)
|
||||
|
||||
old_state = @match.state
|
||||
if @match.update(match_params)
|
||||
record_successful_match_update(old_state, new_state) unless new_state.nil?
|
||||
render json: @match
|
||||
tournament
|
||||
else
|
||||
render json: @match.errors, status: :unprocessable_content
|
||||
raise ActiveRecord::Rollback
|
||||
end
|
||||
end
|
||||
|
||||
def validate_transition_inside_transaction(new_state)
|
||||
return if new_state.nil?
|
||||
|
||||
transition_error = match_transition_error(@match, new_state)
|
||||
return if transition_error.nil?
|
||||
|
||||
render json: { error: transition_error }, status: :unprocessable_content
|
||||
raise ActiveRecord::Rollback
|
||||
end
|
||||
|
||||
def record_successful_match_update(old_state, new_state)
|
||||
handle_match_end if new_state == 'finished'
|
||||
handle_match_stop if new_state == 'not_started'
|
||||
update_group_scores if @match.group_match? && %w[in_progress not_started].include?(new_state)
|
||||
TournamentTransactionLogger.record_match_state_change!(
|
||||
match: @match,
|
||||
user: current_user,
|
||||
old_state: old_state,
|
||||
new_state: new_state,
|
||||
details: {
|
||||
group_scores: @transaction_log_group_scores,
|
||||
advanced_match_ids: @transaction_log_advanced_match_ids
|
||||
}
|
||||
)
|
||||
end
|
||||
|
||||
def validate_params
|
||||
case match_params['state']
|
||||
when 'in_progress'
|
||||
render json: { error: 'Match can\'t start in this state' }, status: :unprocessable_entity unless @match.not_started?
|
||||
when 'finished'
|
||||
render json: { error: 'Match can\'t finish in this state' }, status: :unprocessable_entity unless @match.in_progress?
|
||||
else
|
||||
render json: { error: 'Invalid target state' }, status: :unprocessable_entity
|
||||
end
|
||||
return unless params.key?(:state)
|
||||
|
||||
transition_error = match_transition_error(@match, match_params['state'])
|
||||
return if transition_error.nil?
|
||||
|
||||
render json: { error: transition_error }, status: :unprocessable_content
|
||||
end
|
||||
|
||||
def set_match
|
||||
|
|
@ -142,13 +230,52 @@ class MatchesController < ApplicationController
|
|||
end
|
||||
|
||||
def match_params
|
||||
params.slice(:state).permit!
|
||||
params.slice(:state, :location, :start_time, :notes).permit!
|
||||
end
|
||||
|
||||
def match_index_params
|
||||
params.slice(:state, :stage_id, :stage_level, :position, :group_stage_position).permit!
|
||||
end
|
||||
|
||||
def match_metadata_params
|
||||
params.slice(:location, :start_time, :notes).permit!
|
||||
end
|
||||
|
||||
def bulk_metadata_matches
|
||||
return bulk_metadata_matches_by_id if params.key?(:match_ids)
|
||||
|
||||
return @tournament.matches if ActiveModel::Type::Boolean.new.cast(params[:all])
|
||||
return filtered_bulk_metadata_matches if match_index_params.values.any?(&:present?)
|
||||
|
||||
render json: { error: 'Select matches with match_ids, filters, or all=true' }, status: :bad_request
|
||||
[]
|
||||
end
|
||||
|
||||
def bulk_metadata_matches_by_id
|
||||
ids = parse_match_ids
|
||||
return [] if performed?
|
||||
|
||||
matches = @tournament.matches.select { |match| ids.include?(match.id) }
|
||||
missing_ids = ids - matches.map(&:id)
|
||||
if missing_ids.any?
|
||||
render json: { error: "Matches not found in tournament: #{missing_ids.join(', ')}" }, status: :not_found
|
||||
return []
|
||||
end
|
||||
|
||||
ids.map { |id| matches.find { |match| match.id == id } }
|
||||
end
|
||||
|
||||
def filtered_bulk_metadata_matches
|
||||
TournamentMatchesPayload.matches_for(@tournament, match_index_params)
|
||||
end
|
||||
|
||||
def parse_match_ids
|
||||
Array(params[:match_ids]).map { |id| Integer(id.to_s, 10) }.uniq
|
||||
rescue ArgumentError, TypeError
|
||||
render json: { error: 'match_ids must contain only integer ids' }, status: :bad_request
|
||||
[]
|
||||
end
|
||||
|
||||
def create_match_params
|
||||
params.slice(:team_ids).permit(team_ids: [])
|
||||
end
|
||||
|
|
@ -162,12 +289,33 @@ class MatchesController < ApplicationController
|
|||
state: :not_started,
|
||||
position: (@group.matches.maximum(:position) || -1) + 1,
|
||||
decider: true,
|
||||
match_scores: teams.map { |team| MatchScore.new(team:) }
|
||||
match_scores: teams.map { |team| MatchScore.new(team: team) }
|
||||
).tap do |match|
|
||||
match.errors.add(:team_ids, 'must contain exactly two teams from this group') if team_ids.size != 2 || teams.size != 2
|
||||
end
|
||||
end
|
||||
|
||||
def match_transition_error(match, new_state)
|
||||
case new_state
|
||||
when 'in_progress'
|
||||
'Match can\'t start in this state' unless match.not_started?
|
||||
when 'finished'
|
||||
'Match can\'t finish in this state' unless match.in_progress?
|
||||
when 'not_started'
|
||||
'Match can\'t stop in this state' unless match.in_progress?
|
||||
else
|
||||
'Invalid target state'
|
||||
end
|
||||
end
|
||||
|
||||
def group_scores_for_log(group)
|
||||
group.group_scores.includes(:team).reload
|
||||
end
|
||||
|
||||
def advanced_match_ids_for(result)
|
||||
Array(result.object_to_save).flatten.grep(Match).reject { |match| match.id == @match.id }.map(&:id)
|
||||
end
|
||||
|
||||
def push_sync_if_needed!(tournament)
|
||||
TournamentSyncEnqueue.call(tournament)
|
||||
end
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
@ -13,64 +13,138 @@ class StagesController < ApplicationController
|
|||
|
||||
# PUT /stages/1
|
||||
def update
|
||||
if stage_params[:state] == 'finished'
|
||||
unless @stage.state == 'in_progress'
|
||||
render json: { error: 'Only running group stages can be finished' }, status: :unprocessable_entity
|
||||
return
|
||||
end
|
||||
return render_invalid_stage_state unless stage_params[:state] == 'finished'
|
||||
return render_stage_not_running unless @stage.in_progress?
|
||||
|
||||
synced_tournament = nil
|
||||
Stage.transaction do
|
||||
if @stage.update(stage_params)
|
||||
handle_group_stage_end
|
||||
|
||||
render json: @stage
|
||||
synced_tournament = @stage.tournament
|
||||
else
|
||||
render json: @stage.errors, status: :unprocessable_entity
|
||||
raise ActiveRecord::Rollback
|
||||
end
|
||||
end
|
||||
push_sync_if_needed!(synced_tournament) unless synced_tournament.nil?
|
||||
TournamentLiveBroadcast.call(synced_tournament.id) unless synced_tournament.nil?
|
||||
else
|
||||
render json: {
|
||||
error: 'The state attribute may only be changed to finished'
|
||||
}, status: :unprocessable_entity
|
||||
end
|
||||
finish_group_stage
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def handle_group_stage_end
|
||||
unless @stage.over?
|
||||
render json: {
|
||||
error: 'Group Stage still has some matches that are not over yet. Finish them to generate playoffs'
|
||||
}, status: :unprocessable_entity
|
||||
def finish_group_stage
|
||||
synced_tournament = nil
|
||||
@playoff_start_failure = nil
|
||||
@transaction_log_advancing_teams = []
|
||||
|
||||
Stage.transaction do
|
||||
synced_tournament = finish_group_stage_inside_transaction
|
||||
end
|
||||
record_playoff_start_failure if @playoff_start_failure
|
||||
push_sync_if_needed!(synced_tournament) unless synced_tournament.nil?
|
||||
TournamentLiveBroadcast.call(synced_tournament.id) unless synced_tournament.nil?
|
||||
end
|
||||
|
||||
def finish_group_stage_inside_transaction
|
||||
TournamentTransactionLogger.lock_tournament!(@stage.tournament)
|
||||
@stage.reload
|
||||
return unless stage_running_for_playoff_start?
|
||||
return unless group_stage_over_for_playoff_start?
|
||||
return unless recalculate_group_scores_for_playoff_start
|
||||
return unless no_blocking_ties_for_playoff_start?
|
||||
|
||||
start_playoffs_inside_transaction
|
||||
end
|
||||
|
||||
def start_playoffs_inside_transaction
|
||||
@transaction_log_advancing_teams = GroupStageService.get_advancing_teams(@stage)
|
||||
if @stage.update(stage_params) &&
|
||||
AddPlayoffsToTournamentAndSave.call(tournament: @stage.tournament,
|
||||
teams: @transaction_log_advancing_teams).success?
|
||||
record_playoffs_started
|
||||
render json: @stage
|
||||
return @stage.tournament
|
||||
elsif @stage.errors.any?
|
||||
render json: @stage.errors, status: :unprocessable_content
|
||||
raise ActiveRecord::Rollback
|
||||
end
|
||||
|
||||
@playoff_start_failure = { reason: 'generation_failed', blocking_ties: [] }
|
||||
render json: { error: 'Generating group stage failed' }, status: :unprocessable_content
|
||||
raise ActiveRecord::Rollback
|
||||
end
|
||||
|
||||
def render_invalid_stage_state
|
||||
render json: {
|
||||
error: 'The state attribute may only be changed to finished'
|
||||
}, status: :unprocessable_content
|
||||
end
|
||||
|
||||
def render_stage_not_running
|
||||
render json: { error: 'Only running group stages can be finished' }, status: :unprocessable_content
|
||||
end
|
||||
|
||||
def stage_running_for_playoff_start?
|
||||
return true if @stage.in_progress?
|
||||
|
||||
render_stage_not_running
|
||||
false
|
||||
end
|
||||
|
||||
def group_stage_over_for_playoff_start?
|
||||
return true if @stage.over?
|
||||
|
||||
render_playoff_start_failure(reason: 'unfinished_matches')
|
||||
false
|
||||
end
|
||||
|
||||
def recalculate_group_scores_for_playoff_start
|
||||
@stage.groups.each do |group|
|
||||
next if UpdateGroupsGroupScoresAndSave.call(group:).success?
|
||||
next if UpdateGroupsGroupScoresAndSave.call(group: group).success?
|
||||
|
||||
render json: { error: 'Updating group scores failed' }, status: :unprocessable_entity
|
||||
render json: { error: 'Updating group scores failed' }, status: :unprocessable_content
|
||||
raise ActiveRecord::Rollback
|
||||
end
|
||||
|
||||
true
|
||||
end
|
||||
|
||||
def no_blocking_ties_for_playoff_start?
|
||||
blocking_ties = GroupStageService.blocking_ties_for(@stage)
|
||||
if blocking_ties.any?
|
||||
render json: {
|
||||
error: 'Playoffs cannot be generated yet because a decider match is required for an advancing place',
|
||||
blocking_ties:
|
||||
}, status: :unprocessable_entity
|
||||
raise ActiveRecord::Rollback
|
||||
return true if blocking_ties.empty?
|
||||
|
||||
render_playoff_start_failure(reason: 'decider_missing', blocking_ties: blocking_ties)
|
||||
false
|
||||
end
|
||||
|
||||
return if AddPlayoffsToTournamentAndSave.call(tournament: @stage.tournament,
|
||||
teams: GroupStageService.get_advancing_teams(@stage)).success?
|
||||
def render_playoff_start_failure(reason:, blocking_ties: [])
|
||||
TournamentTransactionLogger.record_playoffs_start_failed!(
|
||||
stage: @stage,
|
||||
user: current_user,
|
||||
reason: reason,
|
||||
blocking_ties: blocking_ties
|
||||
)
|
||||
render json: playoff_start_failure_payload(reason, blocking_ties), status: :unprocessable_content
|
||||
nil
|
||||
end
|
||||
|
||||
render json: { error: 'Generating group stage failed' }, status: :unprocessable_entity
|
||||
raise ActiveRecord::Rollback
|
||||
def playoff_start_failure_payload(reason, blocking_ties)
|
||||
if reason == 'unfinished_matches'
|
||||
return {
|
||||
error: 'Group Stage still has some matches that are not over yet. Finish them to generate playoffs'
|
||||
}
|
||||
end
|
||||
|
||||
{
|
||||
error: 'Playoffs cannot be generated yet because a decider match is required for an advancing place',
|
||||
blocking_ties: blocking_ties
|
||||
}
|
||||
end
|
||||
|
||||
def record_playoffs_started
|
||||
TournamentTransactionLogger.record_playoffs_started!(
|
||||
stage: @stage,
|
||||
user: current_user,
|
||||
advancing_teams: @transaction_log_advancing_teams
|
||||
)
|
||||
end
|
||||
|
||||
def record_playoff_start_failure
|
||||
TournamentTransactionLogger.record_playoffs_start_failed!(
|
||||
stage: @stage,
|
||||
user: current_user,
|
||||
reason: @playoff_start_failure.fetch(:reason),
|
||||
blocking_ties: @playoff_start_failure.fetch(:blocking_ties)
|
||||
)
|
||||
end
|
||||
|
||||
def set_stage
|
||||
|
|
|
|||
|
|
@ -15,7 +15,7 @@ class TeamActionItemsController < ApplicationController
|
|||
)
|
||||
render json: @team_action_item
|
||||
else
|
||||
render json: @team_action_item.errors, status: :unprocessable_entity
|
||||
render json: @team_action_item.errors, status: :unprocessable_content
|
||||
end
|
||||
end
|
||||
|
||||
|
|
|
|||
|
|
@ -20,9 +20,9 @@ class TeamActionListsController < ApplicationController
|
|||
|
||||
render json: team_action_list, status: :created
|
||||
rescue TeamActionListService::InvalidSourceError, TeamActionListService::EmptySourceError => e
|
||||
render json: { error: e.message }, status: :unprocessable_entity
|
||||
render json: { error: e.message }, status: :unprocessable_content
|
||||
rescue ActiveRecord::RecordInvalid => e
|
||||
render json: e.record.errors, status: :unprocessable_entity
|
||||
render json: e.record.errors, status: :unprocessable_content
|
||||
end
|
||||
|
||||
# PATCH /tournaments/:tournament_id/team_action_lists/:key/teams/:team_id
|
||||
|
|
@ -33,7 +33,7 @@ class TeamActionListsController < ApplicationController
|
|||
)
|
||||
render json: @team_action_item
|
||||
else
|
||||
render json: @team_action_item.errors, status: :unprocessable_entity
|
||||
render json: @team_action_item.errors, status: :unprocessable_content
|
||||
end
|
||||
end
|
||||
|
||||
|
|
|
|||
|
|
@ -3,7 +3,7 @@
|
|||
class TeamsController < ApplicationController
|
||||
before_action :set_team, only: %i[show update]
|
||||
before_action :authenticate_user!, only: %i[update]
|
||||
before_action -> { require_owner! @team.owner }, only: %i[update]
|
||||
before_action :require_team_update_actor!, only: %i[update]
|
||||
before_action -> { require_writable_tournament!(@team.tournament) }, only: %i[update]
|
||||
|
||||
# GET /teams/1
|
||||
|
|
@ -13,12 +13,19 @@ class TeamsController < ApplicationController
|
|||
|
||||
# PATCH/PUT /teams/1
|
||||
def update
|
||||
if @team.update(team_params)
|
||||
Team.transaction do
|
||||
@team.assign_attributes(team_params)
|
||||
assign_players_from_params if player_assignment_requested?
|
||||
raise ActiveRecord::Rollback if performed?
|
||||
|
||||
@team.save!
|
||||
end
|
||||
return if performed?
|
||||
|
||||
push_sync_if_needed!(@team.tournament)
|
||||
render json: @team
|
||||
else
|
||||
render json: @team.errors, status: :unprocessable_entity
|
||||
end
|
||||
rescue ActiveRecord::RecordInvalid => e
|
||||
render json: e.record.errors, status: :unprocessable_content
|
||||
end
|
||||
|
||||
private
|
||||
|
|
@ -31,6 +38,81 @@ class TeamsController < ApplicationController
|
|||
params.slice(:name).permit!
|
||||
end
|
||||
|
||||
def player_assignment_requested?
|
||||
params.key?(:player_email) || params.key?(:player_id) ||
|
||||
params.key?(:player_emails) || params.key?(:player_ids)
|
||||
end
|
||||
|
||||
def assign_players_from_params
|
||||
unless tournament_owner?
|
||||
render_forbidden_error
|
||||
return
|
||||
end
|
||||
|
||||
if params.key?(:player_emails)
|
||||
@team.players = players_from_emails(params[:player_emails])
|
||||
elsif params.key?(:player_ids)
|
||||
@team.players = players_from_ids(params[:player_ids])
|
||||
elsif params.key?(:player_email)
|
||||
add_or_clear_player_from_email
|
||||
else
|
||||
add_or_clear_player_from_id
|
||||
end
|
||||
rescue ActiveRecord::RecordInvalid => e
|
||||
render json: e.record.errors, status: :unprocessable_content
|
||||
end
|
||||
|
||||
def players_from_emails(emails)
|
||||
email_values(emails).map { |email| User.find_or_create_player_by_email!(email) }
|
||||
end
|
||||
|
||||
def players_from_ids(player_ids)
|
||||
id_values = Array.wrap(player_ids).map(&:to_s).map(&:strip).reject(&:blank?)
|
||||
return [] if id_values.empty?
|
||||
|
||||
User.where(id: id_values).tap do |players|
|
||||
raise ActiveRecord::RecordNotFound.new('Could not find all players', 'User') unless players.size == id_values.uniq.size
|
||||
end
|
||||
end
|
||||
|
||||
def add_or_clear_player_from_email
|
||||
email = params[:player_email].to_s.strip
|
||||
if email.blank?
|
||||
@team.players = []
|
||||
return
|
||||
end
|
||||
|
||||
add_player(User.find_or_create_player_by_email!(email))
|
||||
end
|
||||
|
||||
def add_or_clear_player_from_id
|
||||
player_id = params[:player_id].to_s.strip
|
||||
if player_id.blank?
|
||||
@team.players = []
|
||||
return
|
||||
end
|
||||
|
||||
add_player(User.find(player_id))
|
||||
end
|
||||
|
||||
def add_player(player)
|
||||
@team.players << player unless @team.player_ids.include?(player.id)
|
||||
end
|
||||
|
||||
def email_values(emails)
|
||||
Array.wrap(emails).map(&:to_s).map(&:strip).reject(&:blank?)
|
||||
end
|
||||
|
||||
def require_team_update_actor!
|
||||
return if tournament_owner? || @team.players.include?(current_user)
|
||||
|
||||
render_forbidden_error
|
||||
end
|
||||
|
||||
def tournament_owner?
|
||||
@team.owner == current_user
|
||||
end
|
||||
|
||||
def push_sync_if_needed!(tournament)
|
||||
TournamentSyncEnqueue.call(tournament)
|
||||
end
|
||||
|
|
|
|||
|
|
@ -0,0 +1,18 @@
|
|||
# frozen_string_literal: true
|
||||
|
||||
class TournamentTransactionLogEntriesController < ApplicationController
|
||||
before_action :set_tournament
|
||||
before_action :authenticate_user!
|
||||
before_action -> { require_owner! @tournament.owner }
|
||||
|
||||
def index
|
||||
entries = @tournament.transaction_log_entries.includes(:user).order(:sequence)
|
||||
render json: entries, each_serializer: TournamentTransactionLogEntrySerializer
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def set_tournament
|
||||
@tournament = Tournament.find(params[:id])
|
||||
end
|
||||
end
|
||||
|
|
@ -12,15 +12,19 @@ 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 destroy_playoffs set_timer timer sync_state test_sync start_next_group_matches]
|
||||
before_action :authenticate_user!,
|
||||
only: %i[create update destroy destroy_playoffs set_timer test_sync start_next_group_matches]
|
||||
before_action -> { require_owner! @tournament.owner },
|
||||
only: %i[update destroy destroy_playoffs 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[destroy_playoffs start_next_group_matches]
|
||||
before_action :authorize_sync_request!, only: %i[sync_state]
|
||||
rescue_from ActiveRecord::RecordNotFound, with: :render_not_found_error
|
||||
|
||||
|
|
@ -62,9 +66,10 @@ class TournamentsController < ApplicationController
|
|||
|
||||
if tournament.read_only_mode?
|
||||
if tournament.save
|
||||
TournamentTransactionLogger.record_tournament_created!(tournament: tournament, user: current_user)
|
||||
render json: tournament, status: :created, location: tournament
|
||||
else
|
||||
render json: tournament.errors, status: :unprocessable_entity
|
||||
render json: tournament.errors, status: :unprocessable_content
|
||||
end
|
||||
return
|
||||
end
|
||||
|
|
@ -75,7 +80,7 @@ class TournamentsController < ApplicationController
|
|||
params.require(:playoff_teams_amount)
|
||||
groups = organize_teams_in_groups(teams)
|
||||
# add groups to tournament
|
||||
result = AddGroupStageToTournamentAndSave.call(tournament:, groups:)
|
||||
result = AddGroupStageToTournamentAndSave.call(tournament: tournament, groups: groups)
|
||||
# associate provided teams with tournament on success
|
||||
tournament.teams = groups.flatten if result.success?
|
||||
else
|
||||
|
|
@ -84,18 +89,19 @@ class TournamentsController < ApplicationController
|
|||
# associate provided teams with tournament
|
||||
tournament.teams = teams
|
||||
# add playoff stage to tournament
|
||||
result = AddPlayoffsToTournamentAndSave.call(tournament:, teams: tournament.teams)
|
||||
result = AddPlayoffsToTournamentAndSave.call(tournament: tournament, teams: tournament.teams)
|
||||
end
|
||||
# validate tournament
|
||||
unless tournament.valid?
|
||||
render json: tournament.errors, status: :unprocessable_entity
|
||||
render json: tournament.errors, status: :unprocessable_content
|
||||
return
|
||||
end
|
||||
# return appropriate result
|
||||
if result.success?
|
||||
TournamentTransactionLogger.record_tournament_created!(tournament: result.tournament, user: current_user)
|
||||
render json: result.tournament, status: :created, location: result.tournament
|
||||
else
|
||||
render json: { error: 'Tournament generation failed' }, status: :unprocessable_entity
|
||||
render json: { error: 'Tournament generation failed' }, status: :unprocessable_content
|
||||
end
|
||||
end
|
||||
|
||||
|
|
@ -114,7 +120,7 @@ class TournamentsController < ApplicationController
|
|||
synced_tournament = @tournament
|
||||
render json: @tournament
|
||||
else
|
||||
render json: @tournament.errors, status: :unprocessable_entity
|
||||
render json: @tournament.errors, status: :unprocessable_content
|
||||
raise ActiveRecord::Rollback
|
||||
end
|
||||
end
|
||||
|
|
@ -126,6 +132,30 @@ class TournamentsController < ApplicationController
|
|||
@tournament.destroy
|
||||
end
|
||||
|
||||
# DELETE /tournaments/:id/playoffs
|
||||
def destroy_playoffs
|
||||
synced_tournament = nil
|
||||
|
||||
Tournament.transaction do
|
||||
TournamentTransactionLogger.lock_tournament!(@tournament)
|
||||
@tournament.reload
|
||||
|
||||
result = TournamentPlayoffDeletion.call(tournament: @tournament)
|
||||
unless result.success?
|
||||
render json: { error: result.error }, status: :unprocessable_content
|
||||
raise ActiveRecord::Rollback
|
||||
end
|
||||
|
||||
synced_tournament = @tournament.reload
|
||||
render json: playoff_deletion_payload(synced_tournament)
|
||||
end
|
||||
|
||||
return if synced_tournament.nil?
|
||||
|
||||
push_sync_if_needed!(synced_tournament)
|
||||
TournamentLiveBroadcast.call(synced_tournament.id)
|
||||
end
|
||||
|
||||
# GET /tournaments/:id/timer
|
||||
def timer
|
||||
render json: serialize_timer_state(@tournament)
|
||||
|
|
@ -133,27 +163,44 @@ class TournamentsController < ApplicationController
|
|||
|
||||
# PATCH /tournaments/:id/timer
|
||||
def set_timer
|
||||
synced_tournament = nil
|
||||
|
||||
Tournament.transaction do
|
||||
TournamentTransactionLogger.lock_tournament!(@tournament)
|
||||
@tournament.reload
|
||||
old_timer = serialize_timer_state(@tournament)
|
||||
|
||||
if @tournament.update(timer_params)
|
||||
push_sync_if_needed!(@tournament)
|
||||
TournamentTransactionLogger.record_timer_changed!(
|
||||
tournament: @tournament,
|
||||
user: current_user,
|
||||
old_timer: old_timer,
|
||||
new_timer: serialize_timer_state(@tournament)
|
||||
)
|
||||
synced_tournament = @tournament
|
||||
render json: @tournament
|
||||
else
|
||||
render json: @tournament.errors, status: :unprocessable_entity
|
||||
render json: @tournament.errors, status: :unprocessable_content
|
||||
raise ActiveRecord::Rollback
|
||||
end
|
||||
end
|
||||
|
||||
push_sync_if_needed!(synced_tournament) unless synced_tournament.nil?
|
||||
end
|
||||
|
||||
def sync_state
|
||||
TournamentSnapshotImporter.import!(tournament: @tournament, snapshot: sync_state_params)
|
||||
TournamentLiveBroadcast.call(@tournament.id)
|
||||
render json: @tournament, include: '**'
|
||||
rescue KeyError, ArgumentError => e
|
||||
render json: { error: e.message }, status: :unprocessable_entity
|
||||
render json: { error: e.message }, status: :unprocessable_content
|
||||
end
|
||||
|
||||
def test_sync
|
||||
unless @tournament.sync_push_enabled?
|
||||
return render json: {
|
||||
error: 'Tournament sync test requires writable tournament with sync_target_url and sync_auth_token'
|
||||
}, status: :unprocessable_entity
|
||||
}, status: :unprocessable_content
|
||||
end
|
||||
|
||||
TournamentSyncPusher.push!(@tournament)
|
||||
|
|
@ -166,19 +213,52 @@ 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:, user: current_user)
|
||||
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
|
||||
|
||||
def timer_params
|
||||
{
|
||||
timestamp: params[:timestamp],
|
||||
timer_mode: params[:timer_mode]
|
||||
timer_params = {
|
||||
timer_reason: params[:timer_reason],
|
||||
timer_reason_text: params[:timer_reason_text]
|
||||
}
|
||||
timer_params[:timestamp] = params[:timestamp] if params.key?(:timestamp)
|
||||
timer_params[:timer_mode] = params[:timer_mode] if params.key?(:timer_mode)
|
||||
timer_params
|
||||
end
|
||||
|
||||
def serialize_timer_state(tournament)
|
||||
{
|
||||
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
|
||||
|
||||
def playoff_deletion_payload(tournament)
|
||||
{
|
||||
id: tournament.id,
|
||||
stages: tournament.stages.sort_by(&:level).map do |stage|
|
||||
{
|
||||
id: stage.id,
|
||||
level: stage.level,
|
||||
state: stage.state
|
||||
}
|
||||
end
|
||||
}
|
||||
end
|
||||
|
||||
|
|
@ -196,7 +276,14 @@ class TournamentsController < ApplicationController
|
|||
if team[:id]
|
||||
Team.find team[:id]
|
||||
elsif team[:name]
|
||||
Team.create name: team[:name]
|
||||
Team.create(name: team[:name], players: players_from_team_params(team))
|
||||
end
|
||||
end
|
||||
|
||||
def players_from_team_params(team)
|
||||
player_emails = team[:player_emails].presence || team[:player_email]
|
||||
Array.wrap(player_emails).map(&:to_s).map(&:strip).reject(&:blank?).map do |player_email|
|
||||
User.find_or_create_player_by_email!(player_email)
|
||||
end
|
||||
end
|
||||
|
||||
|
|
@ -209,7 +296,7 @@ class TournamentsController < ApplicationController
|
|||
@tournament = profiling.measure('load_tournament') do
|
||||
Tournament.includes(
|
||||
:user,
|
||||
:teams,
|
||||
{ teams: :players },
|
||||
stages: [
|
||||
{ matches: { match_scores: :team } },
|
||||
{ groups: [
|
||||
|
|
@ -245,6 +332,7 @@ class TournamentsController < ApplicationController
|
|||
params.slice(
|
||||
:name, :description, :public, :teams, :group_stage, :playoff_teams_amount,
|
||||
:instant_finalists_amount, :intermediate_round_participants_amount,
|
||||
:location, :starts_at, :ends_at, :website_url,
|
||||
:read_only_mode, :sync_target_url, :sync_auth_token
|
||||
).permit!
|
||||
end
|
||||
|
|
@ -253,9 +341,9 @@ class TournamentsController < ApplicationController
|
|||
return if ActiveModel::Type::Boolean.new.cast(params[:read_only_mode])
|
||||
|
||||
teams = params['teams']
|
||||
return if teams.is_a?(Array) && teams.count { |t| !(t.is_a? ActionController::Parameters) }.zero?
|
||||
return if teams.is_a?(Array) && teams.count { |team| !team.is_a?(ActionController::Parameters) }.zero?
|
||||
|
||||
render json: { error: 'Invalid teams array' }, status: :unprocessable_entity
|
||||
render json: { error: 'Invalid teams array' }, status: :unprocessable_content
|
||||
end
|
||||
|
||||
def only_playoff_teams_amount_changed
|
||||
|
|
@ -286,7 +374,7 @@ class TournamentsController < ApplicationController
|
|||
|
||||
render json: {
|
||||
error: 'playoff_teams_amount, instant_finalists_amount and intermediate_round_participants_amount don\'t match'
|
||||
}, status: :unprocessable_entity
|
||||
}, status: :unprocessable_content
|
||||
end
|
||||
|
||||
def sync_state_params
|
||||
|
|
@ -296,15 +384,13 @@ class TournamentsController < ApplicationController
|
|||
def authorize_sync_request!
|
||||
unless @tournament.sync_accepts_push?
|
||||
return render json: { error: 'Tournament is not configured to accept sync pushes' },
|
||||
status: :unprocessable_entity
|
||||
status: :unprocessable_content
|
||||
end
|
||||
|
||||
token = request.authorization.to_s.delete_prefix('Bearer ').presence || request.headers['X-Tournament-Sync-Token'].to_s
|
||||
return render json: { error: 'Missing sync token' }, status: :unauthorized if token.blank?
|
||||
|
||||
matches = token.bytesize == @tournament.sync_auth_token.to_s.bytesize &&
|
||||
ActiveSupport::SecurityUtils.secure_compare(token, @tournament.sync_auth_token.to_s)
|
||||
return if matches
|
||||
return if @tournament.sync_token_matches?(token)
|
||||
|
||||
render json: { error: 'Invalid sync token' }, status: :unauthorized
|
||||
end
|
||||
|
|
@ -337,72 +423,125 @@ class TournamentsController < ApplicationController
|
|||
timestamp = params[:timestamp]
|
||||
timestamp_seconds = params[:timestamp_seconds]
|
||||
timer_mode = params[:timer_mode]
|
||||
timer_reason_param_present = params.key?(:timer_reason)
|
||||
timer_reason_text_param_present = params.key?(:timer_reason_text)
|
||||
timer_reason_update_requested = timer_reason_param_present || timer_reason_text_param_present
|
||||
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])
|
||||
|
||||
if clear_timer
|
||||
if timestamp.present? || timestamp_seconds.present? || timer_mode.present?
|
||||
return render json: { error: 'clear_timer cannot be combined with timestamp values or timer_mode' },
|
||||
status: :unprocessable_entity
|
||||
return validate_clear_timer_params(timestamp, timestamp_seconds, timer_mode, timer_reason, timer_reason_text) if clear_timer
|
||||
|
||||
return render_timer_validation_error('Invalid timer_mode') if timer_mode.present? && !Tournament::TIMER_MODES.include?(timer_mode)
|
||||
|
||||
timer_reason_error = validate_timer_reason_params(
|
||||
timer_reason,
|
||||
timer_reason_text,
|
||||
timer_reason_param_present,
|
||||
timer_reason_text_param_present
|
||||
)
|
||||
return render_timer_validation_error(timer_reason_error) if timer_reason_error
|
||||
|
||||
return if validate_timer_timestamp_params(timestamp, timestamp_seconds, timer_mode)
|
||||
|
||||
if timer_mode == 'countup'
|
||||
params[:timestamp] = Time.zone.now
|
||||
elsif timer_mode.present?
|
||||
render_timer_validation_error('timer_mode requires timestamp, timestamp_seconds, or clear_timer')
|
||||
elsif timer_reason_update_requested
|
||||
render_timer_validation_error('timer_reason requires active timer') unless timer_active?
|
||||
else
|
||||
render_timer_validation_error('timestamp, timestamp_seconds, or clear_timer is required')
|
||||
end
|
||||
end
|
||||
|
||||
params[:timestamp] = nil
|
||||
params[:timer_mode] = nil
|
||||
return
|
||||
end
|
||||
|
||||
if timer_mode.present? && %w[countdown countup].exclude?(timer_mode)
|
||||
return render json: { error: 'Invalid timer_mode' }, status: :unprocessable_entity
|
||||
end
|
||||
|
||||
# throw error if both timestamp and timestamp_seconds are present
|
||||
def validate_timer_timestamp_params(timestamp, timestamp_seconds, timer_mode)
|
||||
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
|
||||
|
||||
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
|
||||
|
||||
if timestamp_seconds.present?
|
||||
begin
|
||||
timestamp_seconds = Integer(timestamp_seconds)
|
||||
rescue ArgumentError
|
||||
return render json: { error: 'Invalid seconds format' }, status: :unprocessable_entity
|
||||
return validate_timestamp_seconds_param(timestamp_seconds) if timestamp_seconds.present?
|
||||
return validate_timestamp_param(timestamp, timer_mode) if timestamp.present?
|
||||
|
||||
false
|
||||
end
|
||||
|
||||
if timestamp_seconds <= 0
|
||||
return render json: { error: 'Timestamp must be in the future for countdown mode' },
|
||||
status: :unprocessable_entity
|
||||
end
|
||||
def validate_timestamp_seconds_param(timestamp_seconds)
|
||||
seconds = Integer(timestamp_seconds)
|
||||
return render_timer_validation_error('Timestamp must be in the future for countdown mode') if seconds <= 0
|
||||
|
||||
parsed_time = Time.zone.now + timestamp_seconds
|
||||
params[:timestamp] = parsed_time
|
||||
params[:timestamp] = Time.zone.now + seconds
|
||||
params[:timer_mode] = 'countdown'
|
||||
elsif timestamp.present?
|
||||
begin
|
||||
rescue ArgumentError
|
||||
render_timer_validation_error('Invalid seconds format')
|
||||
end
|
||||
|
||||
def validate_timestamp_param(timestamp, timer_mode)
|
||||
parsed_time = Time.zone.parse(timestamp)
|
||||
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?
|
||||
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
|
||||
|
||||
params[:timestamp] = parsed_time
|
||||
params[:timer_mode] = timer_mode.presence || 'countdown'
|
||||
rescue ArgumentError
|
||||
render json: { error: 'Invalid timestamp format' }, status: :unprocessable_entity
|
||||
end
|
||||
elsif timer_mode == 'countup'
|
||||
params[:timestamp] = Time.zone.now
|
||||
elsif timer_mode.present?
|
||||
render json: { error: 'timer_mode requires timestamp, timestamp_seconds, or clear_timer' },
|
||||
status: :unprocessable_entity
|
||||
else
|
||||
render json: { error: 'timestamp, timestamp_seconds, or clear_timer is required' },
|
||||
status: :unprocessable_entity
|
||||
render_timer_validation_error('Invalid timestamp format')
|
||||
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,
|
||||
timer_reason_param_present,
|
||||
timer_reason_text_param_present
|
||||
)
|
||||
return 'timer_reason is required when timer_reason_text is provided' if timer_reason_text_param_present && !timer_reason_param_present
|
||||
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)
|
||||
|
||||
return 'timer_reason_text is required for custom timer_reason' if timer_reason == 'custom' && timer_reason_text.blank?
|
||||
|
||||
return unless timer_reason != 'custom' && timer_reason_text.present?
|
||||
|
||||
'timer_reason_text is only allowed when timer_reason is custom'
|
||||
end
|
||||
|
||||
def timer_active?
|
||||
@tournament.timestamp.present? && @tournament.timer_mode.present?
|
||||
end
|
||||
|
||||
def render_timer_validation_error(message)
|
||||
render json: { error: message }, status: :unprocessable_content
|
||||
end
|
||||
end
|
||||
|
|
|
|||
|
|
@ -8,12 +8,6 @@ class VersionController < ApplicationController
|
|||
private
|
||||
|
||||
def git_commit_sha
|
||||
ENV['GIT_COMMIT_SHA'].presence || local_git_commit_sha || 'unknown'
|
||||
end
|
||||
|
||||
def local_git_commit_sha
|
||||
Rails.root.join('.git').exist? ? `git rev-parse HEAD`.strip.presence : nil
|
||||
rescue StandardError
|
||||
nil
|
||||
ENV['GIT_COMMIT_SHA'].presence || 'aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa'
|
||||
end
|
||||
end
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
# frozen_string_literal: true
|
||||
|
||||
class Match < ApplicationRecord
|
||||
enum state: { single_team: 0, not_ready: 1, not_started: 2, in_progress: 3, finished: 4,
|
||||
enum :state, { single_team: 0, not_ready: 1, not_started: 2, in_progress: 3, finished: 4,
|
||||
undecided: 5 }
|
||||
|
||||
belongs_to :stage, optional: true
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
# frozen_string_literal: true
|
||||
|
||||
class Stage < ApplicationRecord
|
||||
enum state: { playoff_stage: 0, intermediate_stage: 1, in_progress: 2, finished: 3 }
|
||||
enum :state, { playoff_stage: 0, intermediate_stage: 1, in_progress: 2, finished: 3 }
|
||||
|
||||
belongs_to :tournament
|
||||
has_many :matches, dependent: :destroy
|
||||
|
|
|
|||
|
|
@ -2,6 +2,8 @@
|
|||
|
||||
class Team < ApplicationRecord
|
||||
belongs_to :tournament, optional: true
|
||||
has_many :team_players, dependent: :destroy
|
||||
has_many :players, through: :team_players
|
||||
has_many :group_scores, dependent: :destroy
|
||||
has_many :match_scores, dependent: :destroy
|
||||
has_many :bets, dependent: :destroy
|
||||
|
|
@ -9,5 +11,5 @@ class Team < ApplicationRecord
|
|||
|
||||
validates :name, presence: true
|
||||
|
||||
delegate :owner, to: :tournament
|
||||
delegate :owner, to: :tournament, allow_nil: true
|
||||
end
|
||||
|
|
|
|||
|
|
@ -0,0 +1,6 @@
|
|||
# frozen_string_literal: true
|
||||
|
||||
class TeamPlayer < ApplicationRecord
|
||||
belongs_to :team
|
||||
belongs_to :player, class_name: 'User'
|
||||
end
|
||||
|
|
@ -1,26 +1,38 @@
|
|||
# frozen_string_literal: true
|
||||
|
||||
require 'securerandom'
|
||||
require 'openssl'
|
||||
|
||||
class Tournament < ApplicationRecord
|
||||
TIMER_MODES = %w[countdown countup].freeze
|
||||
TIMER_REASONS = %w[tournament_start playoff_start match_end custom].freeze
|
||||
METADATA_STRING_ATTRIBUTES = %i[location website_url].freeze
|
||||
|
||||
belongs_to :user
|
||||
belongs_to :owner, class_name: 'User', foreign_key: :user_id, inverse_of: :tournaments
|
||||
has_many :teams, dependent: :destroy
|
||||
has_many :stages, dependent: :destroy
|
||||
has_many :beamers, dependent: :destroy
|
||||
has_many :team_action_lists, dependent: :destroy
|
||||
has_many :transaction_log_entries, class_name: 'TournamentTransactionLogEntry', dependent: :destroy
|
||||
|
||||
validates :name, presence: 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 :website_url, format: URI::DEFAULT_PARSER.make_regexp(%w[http https]), allow_blank: true
|
||||
validate :sync_configuration_is_complete
|
||||
|
||||
validate :playoff_teams_amount_is_positive_power_of_two
|
||||
|
||||
alias_attribute :owner, :user
|
||||
validate :timer_reason_text_matches_reason
|
||||
validate :ends_at_is_after_starts_at
|
||||
|
||||
after_initialize :generate_code
|
||||
after_create_commit :ensure_default_beamer!
|
||||
before_validation :normalize_timer_reason
|
||||
before_validation :normalize_metadata
|
||||
before_validation :protect_follower_sync_token
|
||||
before_validation :clear_follower_sync_token_when_disabling_read_only_mode
|
||||
after_commit :broadcast_timer_state_change, if: :saved_change_to_timer_state?
|
||||
|
||||
|
|
@ -45,7 +57,27 @@ class Tournament < ApplicationRecord
|
|||
end
|
||||
|
||||
def sync_accepts_push?
|
||||
read_only_mode? && sync_auth_token.present?
|
||||
read_only_mode? && sync_auth_configured?
|
||||
end
|
||||
|
||||
def sync_auth_configured?
|
||||
sync_auth_token.present? || sync_auth_token_digest.present?
|
||||
end
|
||||
|
||||
def sync_token_matches?(token)
|
||||
return false if token.blank?
|
||||
|
||||
if sync_auth_token_digest.present?
|
||||
expected_digest = self.class.sync_auth_token_digest(token)
|
||||
return ActiveSupport::SecurityUtils.secure_compare(expected_digest, sync_auth_token_digest)
|
||||
end
|
||||
|
||||
token.bytesize == sync_auth_token.to_s.bytesize &&
|
||||
ActiveSupport::SecurityUtils.secure_compare(token, sync_auth_token.to_s)
|
||||
end
|
||||
|
||||
def self.sync_auth_token_digest(token)
|
||||
OpenSSL::HMAC.hexdigest('SHA256', Rails.application.secret_key_base, token.to_s)
|
||||
end
|
||||
|
||||
private
|
||||
|
|
@ -75,15 +107,15 @@ class Tournament < ApplicationRecord
|
|||
end
|
||||
|
||||
def sync_configuration_blank?
|
||||
sync_target_url.blank? && sync_auth_token.blank?
|
||||
sync_target_url.blank? && !sync_auth_configured?
|
||||
end
|
||||
|
||||
def sync_configuration_complete?
|
||||
sync_target_url.present? && sync_auth_token.present?
|
||||
sync_target_url.present? && sync_auth_configured?
|
||||
end
|
||||
|
||||
def follower_sync_token_only?
|
||||
read_only_mode? && sync_auth_token.present? && sync_target_url.blank?
|
||||
read_only_mode? && sync_auth_configured? && sync_target_url.blank?
|
||||
end
|
||||
|
||||
def ensure_default_beamer!
|
||||
|
|
@ -95,13 +127,55 @@ class Tournament < ApplicationRecord
|
|||
|
||||
def clear_follower_sync_token_when_disabling_read_only_mode
|
||||
return if read_only_mode?
|
||||
|
||||
self.sync_auth_token_digest = nil
|
||||
return if sync_target_url.present?
|
||||
|
||||
self.sync_auth_token = nil
|
||||
end
|
||||
|
||||
def protect_follower_sync_token
|
||||
return unless read_only_mode?
|
||||
|
||||
if sync_auth_token.present?
|
||||
self.sync_auth_token_digest = self.class.sync_auth_token_digest(sync_auth_token)
|
||||
self.sync_auth_token = nil
|
||||
elsif will_save_change_to_sync_auth_token? && sync_auth_token.blank?
|
||||
self.sync_auth_token_digest = 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 normalize_metadata
|
||||
METADATA_STRING_ATTRIBUTES.each do |attribute|
|
||||
self[attribute] = self[attribute]&.strip&.presence
|
||||
end
|
||||
end
|
||||
|
||||
def ends_at_is_after_starts_at
|
||||
return if starts_at.blank? || ends_at.blank?
|
||||
return if ends_at > starts_at
|
||||
|
||||
errors.add(:ends_at, 'must be after starts_at')
|
||||
end
|
||||
|
||||
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
|
||||
|
||||
def broadcast_timer_state_change
|
||||
|
|
|
|||
|
|
@ -0,0 +1,10 @@
|
|||
# frozen_string_literal: true
|
||||
|
||||
class TournamentTransactionLogEntry < ApplicationRecord
|
||||
belongs_to :tournament
|
||||
belongs_to :user, optional: true
|
||||
|
||||
validates :sequence, presence: true, numericality: { only_integer: true, greater_than: 0 }
|
||||
validates :action, presence: true
|
||||
validates :message, presence: true
|
||||
end
|
||||
|
|
@ -11,5 +11,38 @@ class User < ApplicationRecord
|
|||
validates :username, presence: true, uniqueness: { case_sensitive: false }
|
||||
|
||||
has_many :tournaments, dependent: :destroy
|
||||
has_many :team_players, foreign_key: :player_id, inverse_of: :player, dependent: :destroy
|
||||
has_many :player_teams, through: :team_players, source: :team
|
||||
has_many :played_tournaments, -> { distinct }, through: :player_teams, source: :tournament
|
||||
has_many :bets, dependent: :destroy
|
||||
|
||||
class << self
|
||||
def find_or_create_player_by_email!(email)
|
||||
normalized_email = email.to_s.strip.downcase
|
||||
where('LOWER(email) = ?', normalized_email).first || create!(email: normalized_email) do |user|
|
||||
generated_password = Devise.friendly_token.first(24)
|
||||
user.username = unique_player_username(normalized_email)
|
||||
user.password = generated_password
|
||||
user.password_confirmation = generated_password
|
||||
user.confirmed_at = Time.current
|
||||
user.uid = normalized_email
|
||||
user.provider = 'email'
|
||||
end
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def unique_player_username(email)
|
||||
base = email.split('@').first.to_s.parameterize.presence || 'player'
|
||||
candidate = base
|
||||
suffix = 2
|
||||
|
||||
while where('LOWER(username) = ?', candidate.downcase).exists?
|
||||
candidate = "#{base}-#{suffix}"
|
||||
suffix += 1
|
||||
end
|
||||
|
||||
candidate
|
||||
end
|
||||
end
|
||||
end
|
||||
|
|
|
|||
|
|
@ -0,0 +1,60 @@
|
|||
# frozen_string_literal: true
|
||||
|
||||
class AccountProfileSerializer < AccountSummarySerializer
|
||||
attributes :tournaments, :created_tournaments, :teams
|
||||
|
||||
def tournaments
|
||||
visible_played_tournaments.map { |tournament| tournament_summary(tournament) }
|
||||
end
|
||||
|
||||
def created_tournaments
|
||||
visible_created_tournaments.map { |tournament| tournament_summary(tournament) }
|
||||
end
|
||||
|
||||
def teams
|
||||
visible_player_teams.map do |team|
|
||||
{
|
||||
id: team.id,
|
||||
name: team.name,
|
||||
tournament: tournament_summary(team.tournament)
|
||||
}
|
||||
end
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def visible_played_tournaments
|
||||
visible_tournaments(object.played_tournaments.order(:created_at))
|
||||
end
|
||||
|
||||
def visible_created_tournaments
|
||||
visible_tournaments(object.tournaments.order(:created_at))
|
||||
end
|
||||
|
||||
def visible_player_teams
|
||||
object.player_teams.includes(:tournament).order(:created_at).select do |team|
|
||||
profile_owner? || team.tournament&.public?
|
||||
end
|
||||
end
|
||||
|
||||
def visible_tournaments(scope)
|
||||
return scope if profile_owner?
|
||||
|
||||
scope.where(public: true)
|
||||
end
|
||||
|
||||
def profile_owner?
|
||||
scope == object
|
||||
end
|
||||
|
||||
def tournament_summary(tournament)
|
||||
return nil if tournament.nil?
|
||||
|
||||
{
|
||||
id: tournament.id,
|
||||
name: tournament.name,
|
||||
code: tournament.code,
|
||||
public: tournament.public
|
||||
}
|
||||
end
|
||||
end
|
||||
|
|
@ -0,0 +1,9 @@
|
|||
# frozen_string_literal: true
|
||||
|
||||
class AccountSummarySerializer < ApplicationSerializer
|
||||
attributes :name, :username
|
||||
|
||||
def name
|
||||
object.username
|
||||
end
|
||||
end
|
||||
|
|
@ -1,7 +1,7 @@
|
|||
# frozen_string_literal: true
|
||||
|
||||
class LiveMatchSerializer < ApplicationSerializer
|
||||
attributes :position, :state, :teams, :group, :stage
|
||||
attributes :position, :state, :location, :start_time, :notes, :teams, :group, :stage
|
||||
|
||||
has_many :match_scores
|
||||
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
# frozen_string_literal: true
|
||||
|
||||
class MatchSerializer < ApplicationSerializer
|
||||
attributes :state, :position, :winner, :decider
|
||||
attributes :state, :position, :winner, :decider, :location, :start_time, :notes
|
||||
|
||||
def winner
|
||||
ActiveModelSerializers::SerializableResource.new(object.winner).as_json
|
||||
|
|
|
|||
|
|
@ -1,5 +1,13 @@
|
|||
# frozen_string_literal: true
|
||||
|
||||
class SimpleTournamentSerializer < ApplicationSerializer
|
||||
attributes :name, :code, :public
|
||||
attributes :name, :code, :public, :location, :website_url
|
||||
|
||||
attribute :starts_at do
|
||||
object.starts_at&.iso8601
|
||||
end
|
||||
|
||||
attribute :ends_at do
|
||||
object.ends_at&.iso8601
|
||||
end
|
||||
end
|
||||
|
|
|
|||
|
|
@ -1,5 +1,9 @@
|
|||
# frozen_string_literal: true
|
||||
|
||||
class TeamSerializer < ApplicationSerializer
|
||||
attributes :name
|
||||
attributes :name, :players
|
||||
|
||||
def players
|
||||
object.players.map { |player| AccountSummarySerializer.new(player).as_json }
|
||||
end
|
||||
end
|
||||
|
|
|
|||
|
|
@ -5,6 +5,7 @@ class TournamentSerializer < SimpleTournamentSerializer
|
|||
:instant_finalists_amount, :intermediate_round_participants_amount
|
||||
attribute :read_only_mode, if: :sync_metadata_visible?
|
||||
attribute :sync_target_url, if: :sync_metadata_visible?
|
||||
attribute :sync_auth_configured, if: :sync_metadata_visible?
|
||||
attribute :sync_last_push_error, if: :sync_metadata_visible?
|
||||
# NEVER expose sync_auth_token anywhere - it should only ever be written to or checked against
|
||||
|
||||
|
|
@ -16,6 +17,8 @@ class TournamentSerializer < SimpleTournamentSerializer
|
|||
end
|
||||
|
||||
attribute :timer_mode
|
||||
attribute :timer_reason
|
||||
attribute :timer_reason_text
|
||||
|
||||
attribute :sync_last_pushed_at, if: :sync_metadata_visible? do
|
||||
object.sync_last_pushed_at&.iso8601
|
||||
|
|
@ -31,6 +34,7 @@ class TournamentSerializer < SimpleTournamentSerializer
|
|||
{
|
||||
id: team.id,
|
||||
name: team.name,
|
||||
players: team.players.map { |player| AccountSummarySerializer.new(player).as_json },
|
||||
advancing_from_group_stage: adv_teams.include?(team)
|
||||
}
|
||||
end
|
||||
|
|
@ -39,4 +43,8 @@ class TournamentSerializer < SimpleTournamentSerializer
|
|||
def sync_metadata_visible?
|
||||
scope.present? && scope == object.owner
|
||||
end
|
||||
|
||||
def sync_auth_configured
|
||||
object.sync_auth_configured?
|
||||
end
|
||||
end
|
||||
|
|
|
|||
|
|
@ -0,0 +1,18 @@
|
|||
# frozen_string_literal: true
|
||||
|
||||
class TournamentTransactionLogEntrySerializer < ApplicationSerializer
|
||||
attributes :id, :sequence, :action, :message, :metadata
|
||||
|
||||
attribute :created_at do
|
||||
object.created_at&.iso8601
|
||||
end
|
||||
|
||||
attribute :user do
|
||||
next nil if object.user.nil?
|
||||
|
||||
{
|
||||
id: object.user.id,
|
||||
username: object.user.username
|
||||
}
|
||||
end
|
||||
end
|
||||
|
|
@ -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!
|
||||
|
|
@ -0,0 +1,60 @@
|
|||
# frozen_string_literal: true
|
||||
|
||||
class TournamentGroupMatchStarter
|
||||
class << self
|
||||
def upcoming_matches(tournament)
|
||||
group_matches = not_started_group_matches(tournament)
|
||||
lowest_position = group_matches.minimum(:position)
|
||||
return [] if lowest_position.nil?
|
||||
|
||||
selected_lowest_matches(group_matches, lowest_position)
|
||||
end
|
||||
|
||||
def start_next!(tournament, logger: Rails.logger, user: nil)
|
||||
started_matches = []
|
||||
|
||||
Match.transaction do
|
||||
TournamentTransactionLogger.lock_tournament!(tournament)
|
||||
started_matches = upcoming_matches(tournament)
|
||||
started_matches.each { |match| match.update!(state: :in_progress) }
|
||||
recalculate_group_scores!(started_matches, logger:)
|
||||
TournamentTransactionLogger.record_group_matches_started!(
|
||||
tournament: tournament,
|
||||
user: user,
|
||||
matches: started_matches
|
||||
)
|
||||
end
|
||||
|
||||
started_matches
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def not_started_group_matches(tournament)
|
||||
group_stage = tournament.group_stage
|
||||
return Match.none if group_stage.nil?
|
||||
|
||||
group_ids = group_stage.groups.ids
|
||||
return Match.none if group_ids.empty?
|
||||
|
||||
Match.where(group_id: group_ids).not_started
|
||||
end
|
||||
|
||||
def selected_lowest_matches(group_matches, lowest_position)
|
||||
group_matches.where(position: lowest_position)
|
||||
.includes(:group)
|
||||
.group_by(&:group_id)
|
||||
.values
|
||||
.map { |matches| matches.min_by(&:id) }
|
||||
.sort_by { |match| [match.position, match.group.number, match.id] }
|
||||
end
|
||||
|
||||
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
|
||||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -0,0 +1,56 @@
|
|||
# frozen_string_literal: true
|
||||
|
||||
class TournamentPlayoffDeletion
|
||||
DELETABLE_MATCH_STATES = %w[not_started not_ready single_team].freeze
|
||||
|
||||
Result = Struct.new(:success, :error, keyword_init: true) do
|
||||
def success?
|
||||
success
|
||||
end
|
||||
end
|
||||
|
||||
def self.call(tournament:)
|
||||
new(tournament).call
|
||||
end
|
||||
|
||||
def initialize(tournament)
|
||||
@tournament = tournament
|
||||
end
|
||||
|
||||
def call
|
||||
group_stage = tournament.group_stage
|
||||
return failure('Only group-stage playoffs can be deleted') if group_stage.nil?
|
||||
|
||||
playoff_stages = tournament.stages.reload.select { |stage| stage.level >= 0 }
|
||||
return failure('Playoffs have not been generated') if playoff_stages.empty?
|
||||
return failure('Playoffs cannot be deleted after matches have started or scores changed') unless playoffs_deletable?(playoff_stages)
|
||||
|
||||
playoff_stages.each(&:destroy!)
|
||||
group_stage.update!(state: :in_progress)
|
||||
success
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
attr_reader :tournament
|
||||
|
||||
def playoffs_deletable?(playoff_stages)
|
||||
playoff_stages.flat_map(&:matches).all? do |match|
|
||||
DELETABLE_MATCH_STATES.include?(match.state) && zero_scores?(match)
|
||||
end
|
||||
end
|
||||
|
||||
def zero_scores?(match)
|
||||
match.match_scores.all? do |score|
|
||||
score.points.to_i.zero? && score.hidden_points.to_i.zero?
|
||||
end
|
||||
end
|
||||
|
||||
def success
|
||||
Result.new(success: true)
|
||||
end
|
||||
|
||||
def failure(error)
|
||||
Result.new(success: false, error: error)
|
||||
end
|
||||
end
|
||||
|
|
@ -22,8 +22,12 @@ class TournamentSnapshotBuilder
|
|||
|
||||
def build
|
||||
payload = tournament.attributes.symbolize_keys.slice(*ROOT_ATTRIBUTES)
|
||||
payload[:starts_at] = tournament.starts_at&.iso8601
|
||||
payload[:ends_at] = tournament.ends_at&.iso8601
|
||||
payload[:timestamp] = tournament.timestamp&.iso8601
|
||||
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[:teams] = teams_payload
|
||||
payload[:beamers] = beamers_payload
|
||||
|
|
|
|||
|
|
@ -6,6 +6,8 @@ class TournamentSnapshotImporter
|
|||
code
|
||||
description
|
||||
public
|
||||
location
|
||||
website_url
|
||||
playoff_teams_amount
|
||||
instant_finalists_amount
|
||||
intermediate_round_participants_amount
|
||||
|
|
@ -67,8 +69,12 @@ class TournamentSnapshotImporter
|
|||
def update_tournament!
|
||||
root_attrs = snapshot.slice(*ROOT_ATTRIBUTES)
|
||||
root_attrs[:code] = tournament.code if code_taken_by_another_tournament?(root_attrs[:code])
|
||||
root_attrs[:starts_at] = parse_time(snapshot[:starts_at])
|
||||
root_attrs[:ends_at] = parse_time(snapshot[:ends_at])
|
||||
root_attrs[:timestamp] = parse_time(snapshot[:timestamp])
|
||||
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_last_imported_snapshot_at] = snapshot_generated_at
|
||||
tournament.update!(root_attrs)
|
||||
|
|
@ -137,6 +143,9 @@ class TournamentSnapshotImporter
|
|||
match.state = attrs.fetch(:state)
|
||||
match.position = attrs[:position]
|
||||
match.decider = attrs[:decider]
|
||||
match.location = attrs[:location]
|
||||
match.start_time = attrs[:start_time]
|
||||
match.notes = attrs[:notes]
|
||||
end
|
||||
end
|
||||
|
||||
|
|
|
|||
|
|
@ -9,11 +9,17 @@ class TournamentSyncSchema
|
|||
code
|
||||
description
|
||||
public
|
||||
location
|
||||
starts_at
|
||||
ends_at
|
||||
website_url
|
||||
playoff_teams_amount
|
||||
instant_finalists_amount
|
||||
intermediate_round_participants_amount
|
||||
timestamp
|
||||
timer_mode
|
||||
timer_reason
|
||||
timer_reason_text
|
||||
].freeze,
|
||||
ignored: %w[
|
||||
user_id
|
||||
|
|
@ -22,6 +28,7 @@ class TournamentSyncSchema
|
|||
read_only_mode
|
||||
sync_target_url
|
||||
sync_auth_token
|
||||
sync_auth_token_digest
|
||||
sync_source_tournament_id
|
||||
sync_last_pushed_at
|
||||
sync_last_push_error
|
||||
|
|
@ -45,7 +52,7 @@ class TournamentSyncSchema
|
|||
ignored: %w[created_at updated_at sync_source_id].freeze
|
||||
}.freeze,
|
||||
Match => {
|
||||
synced: %w[id state position stage_id group_id decider].freeze,
|
||||
synced: %w[id state position stage_id group_id decider location start_time notes].freeze,
|
||||
ignored: %w[created_at updated_at sync_source_id].freeze
|
||||
}.freeze,
|
||||
MatchScore => {
|
||||
|
|
|
|||
|
|
@ -7,7 +7,9 @@ class TournamentTimerBroadcastPayload
|
|||
type: 'timer.updated',
|
||||
tournament_id: tournament.id,
|
||||
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
|
||||
|
|
|
|||
|
|
@ -0,0 +1,345 @@
|
|||
# frozen_string_literal: true
|
||||
|
||||
class TournamentTransactionLogger
|
||||
class << self
|
||||
def lock_tournament!(tournament)
|
||||
raise ArgumentError, 'tournament is required' if tournament.nil?
|
||||
|
||||
raise 'Tournament transaction log writes must happen inside a transaction' unless ActiveRecord::Base.connection.transaction_open?
|
||||
|
||||
# Lock a fresh row instance so callers keep saved_change_to_* state for
|
||||
# after_commit callbacks on the object they just updated.
|
||||
Tournament.lock.find(tournament.id)
|
||||
end
|
||||
|
||||
def record!(tournament:, user:, action:, message:, metadata: {})
|
||||
retries = 0
|
||||
|
||||
begin
|
||||
Tournament.transaction(requires_new: true) do
|
||||
lock_tournament!(tournament)
|
||||
TournamentTransactionLogEntry.create!(
|
||||
tournament: tournament,
|
||||
user: user,
|
||||
sequence: next_sequence_for(tournament),
|
||||
action: action,
|
||||
message: message,
|
||||
metadata: metadata.deep_stringify_keys
|
||||
)
|
||||
end
|
||||
rescue ActiveRecord::RecordNotUnique
|
||||
retries += 1
|
||||
retry if retries < 3
|
||||
|
||||
raise
|
||||
end
|
||||
end
|
||||
|
||||
def record_tournament_created!(tournament:, user:)
|
||||
record!(
|
||||
tournament: tournament,
|
||||
user: user,
|
||||
action: 'tournament.created',
|
||||
message: "#{user_label(user)} created tournament #{tournament.name}(#{tournament.id})",
|
||||
metadata: {
|
||||
tournament_id: tournament.id,
|
||||
tournament_name: tournament.name
|
||||
}
|
||||
)
|
||||
end
|
||||
|
||||
def record_timer_changed!(tournament:, user:, old_timer:, new_timer:)
|
||||
record!(
|
||||
tournament: tournament,
|
||||
user: user,
|
||||
action: 'tournament.timer_changed',
|
||||
message: "#{user_label(user)} changed tournament timer for #{tournament.name}(#{tournament.id})",
|
||||
metadata: {
|
||||
old_timer: old_timer,
|
||||
new_timer: new_timer
|
||||
}
|
||||
)
|
||||
end
|
||||
|
||||
def record_match_state_change!(match:, user:, old_state:, new_state:, details: {})
|
||||
match.reload
|
||||
stage_type = match.group_match? ? 'groupstage' : 'playoffs'
|
||||
group_scores = details.fetch(:group_scores, [])
|
||||
advanced_match_ids = details.fetch(:advanced_match_ids, [])
|
||||
metadata = match_metadata(match).merge(
|
||||
old_state: old_state,
|
||||
new_state: new_state,
|
||||
stage_type: stage_type,
|
||||
winner_team_id: match.winner&.id,
|
||||
advanced_match_ids: advanced_match_ids,
|
||||
group_scores: group_scores_metadata(group_scores)
|
||||
)
|
||||
|
||||
record!(
|
||||
tournament: match.tournament,
|
||||
user: user,
|
||||
action: 'match.state_changed',
|
||||
message: match_state_message(
|
||||
match: match,
|
||||
user: user,
|
||||
new_state: new_state,
|
||||
details: details.merge(stage_type: stage_type)
|
||||
),
|
||||
metadata: metadata
|
||||
)
|
||||
end
|
||||
|
||||
def record_match_score_update!(match_score:, user:, changes:, group_scores: [])
|
||||
return if changes.empty?
|
||||
|
||||
match_score.reload
|
||||
match = match_score.match
|
||||
metadata = match_metadata(match).merge(
|
||||
match_score_id: match_score.id,
|
||||
team_id: match_score.team_id,
|
||||
changes: changes,
|
||||
group_scores: group_scores_metadata(group_scores)
|
||||
)
|
||||
|
||||
record!(
|
||||
tournament: match.tournament,
|
||||
user: user,
|
||||
action: 'match_score.updated',
|
||||
message: match_score_message(
|
||||
match_score: match_score,
|
||||
user: user,
|
||||
changes: changes,
|
||||
group_scores: group_scores
|
||||
),
|
||||
metadata: metadata
|
||||
)
|
||||
end
|
||||
|
||||
def record_decider_match_created!(match:, user:, group_scores: [])
|
||||
record!(
|
||||
tournament: match.tournament,
|
||||
user: user,
|
||||
action: 'decider_match.created',
|
||||
message: "#{user_label(user)} added decider match #{match.id} (#{match_teams_label(match)})",
|
||||
metadata: match_metadata(match).merge(group_scores: group_scores_metadata(group_scores))
|
||||
)
|
||||
end
|
||||
|
||||
def record_playoffs_start_failed!(stage:, user:, reason:, blocking_ties: [])
|
||||
record!(
|
||||
tournament: stage.tournament,
|
||||
user: user,
|
||||
action: 'playoffs.start_failed',
|
||||
message: playoffs_start_failed_message(user: user, reason: reason, blocking_ties: blocking_ties),
|
||||
metadata: {
|
||||
stage_id: stage.id,
|
||||
reason: reason,
|
||||
blocking_ties: blocking_ties
|
||||
}
|
||||
)
|
||||
end
|
||||
|
||||
def record_playoffs_started!(stage:, user:, advancing_teams:)
|
||||
record!(
|
||||
tournament: stage.tournament,
|
||||
user: user,
|
||||
action: 'playoffs.started',
|
||||
message: "#{user_label(user)} started playoffs, advancing teams: #{team_list_label(advancing_teams)}",
|
||||
metadata: {
|
||||
stage_id: stage.id,
|
||||
advancing_team_ids: advancing_teams.map(&:id),
|
||||
advancing_teams: teams_metadata(advancing_teams)
|
||||
}
|
||||
)
|
||||
end
|
||||
|
||||
def record_group_matches_started!(tournament:, user:, matches:)
|
||||
return if matches.empty?
|
||||
|
||||
record!(
|
||||
tournament: tournament,
|
||||
user: user,
|
||||
action: 'group_matches.started',
|
||||
message: "#{user_label(user)} started group matches: #{matches.map(&:id).join(', ')}",
|
||||
metadata: {
|
||||
match_ids: matches.map(&:id),
|
||||
matches: matches.map { |match| match_metadata(match) }
|
||||
}
|
||||
)
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def next_sequence_for(tournament)
|
||||
TournamentTransactionLogEntry.where(tournament_id: tournament.id).maximum(:sequence).to_i + 1
|
||||
end
|
||||
|
||||
def user_label(user)
|
||||
user&.username.presence || user&.email.presence || 'system'
|
||||
end
|
||||
|
||||
def state_label(state)
|
||||
{
|
||||
'in_progress' => 'running',
|
||||
'not_started' => 'not started',
|
||||
'not_ready' => 'not ready',
|
||||
'single_team' => 'single team',
|
||||
'undecided' => 'undecided',
|
||||
'finished' => 'finished'
|
||||
}.fetch(state.to_s, state.to_s)
|
||||
end
|
||||
|
||||
def match_state_message(match:, user:, new_state:, details:)
|
||||
group_scores = details.fetch(:group_scores, [])
|
||||
advanced_match_ids = details.fetch(:advanced_match_ids, [])
|
||||
stage_type = details.fetch(:stage_type)
|
||||
|
||||
if new_state.to_s == 'finished'
|
||||
return finished_group_match_message(match, user, group_scores) if match.group_match?
|
||||
|
||||
return finished_playoff_match_message(match, user, stage_type, advanced_match_ids)
|
||||
end
|
||||
|
||||
message = "#{user_label(user)} changed match #{match.id} " \
|
||||
"(#{match_teams_label(match)}) to #{state_label(new_state)}"
|
||||
message += ' - scores reset' if new_state.to_s == 'not_started'
|
||||
message
|
||||
end
|
||||
|
||||
def finished_group_match_message(match, user, group_scores)
|
||||
"#{user_label(user)} changed match #{match.id} to finished (#{finish_result_label(match)}) - " \
|
||||
"groupstage - group scores recalculated (#{group_scores_label(group_scores)})"
|
||||
end
|
||||
|
||||
def finished_playoff_match_message(match, user, stage_type, advanced_match_ids)
|
||||
message = "#{user_label(user)} changed match #{match.id} to finished " \
|
||||
"(#{finish_result_label(match)}) - #{stage_type}"
|
||||
return message if advanced_match_ids.empty? || match.winner.nil?
|
||||
|
||||
"#{message} - #{team_label(match.winner)} advances to match #{advanced_match_ids.join(', ')}"
|
||||
end
|
||||
|
||||
def match_score_message(match_score:, user:, changes:, group_scores:)
|
||||
match = match_score.match
|
||||
change_label = changes.map do |field, values|
|
||||
"#{field} from #{values.fetch(:from)} to #{values.fetch(:to)}"
|
||||
end.join(', ')
|
||||
message = "#{user_label(user)} changed matchscore #{match_score.id} from match #{match.id}: #{change_label} " \
|
||||
"(#{score_detail_label(match_score, changes)})"
|
||||
return message unless match.group_match?
|
||||
|
||||
"#{message} - group scores recalculated (#{group_scores_label(group_scores)})"
|
||||
end
|
||||
|
||||
def playoffs_start_failed_message(user:, reason:, blocking_ties:)
|
||||
case reason.to_s
|
||||
when 'decider_missing'
|
||||
"#{user_label(user)} attempted playoff start, failed because decider missing " \
|
||||
"(#{blocking_ties_label(blocking_ties)})"
|
||||
when 'unfinished_matches'
|
||||
"#{user_label(user)} attempted playoff start, failed because group stage has unfinished matches"
|
||||
else
|
||||
"#{user_label(user)} attempted playoff start, failed because #{reason}"
|
||||
end
|
||||
end
|
||||
|
||||
def finish_result_label(match)
|
||||
winner = match.winner
|
||||
return "#{team_label(winner)} wins" unless winner.nil?
|
||||
|
||||
teams = match.teams
|
||||
return "draw between #{team_label(teams.first)} and #{team_label(teams.second)}" if teams.size == 2
|
||||
|
||||
'no winner'
|
||||
end
|
||||
|
||||
def score_detail_label(match_score, changes)
|
||||
team = match_score.team
|
||||
opponent = match_score.match.teams.find { |candidate| candidate.id != team.id }
|
||||
field = score_detail_field(changes)
|
||||
points = score_detail_points(match_score, changes, field)
|
||||
"#{points} #{field.to_s.tr('_', ' ')} for #{team_label(team)} in match vs #{team_label(opponent)}"
|
||||
end
|
||||
|
||||
def score_detail_field(changes)
|
||||
return :hidden_points if changes.key?(:hidden_points) || changes.key?('hidden_points')
|
||||
|
||||
:points
|
||||
end
|
||||
|
||||
def score_detail_points(match_score, changes, field)
|
||||
changes.dig(field.to_s, :to) ||
|
||||
changes.dig(field, :to) ||
|
||||
match_score.public_send(field)
|
||||
end
|
||||
|
||||
def match_teams_label(match)
|
||||
teams = match.teams
|
||||
return "between #{team_label(teams.first)} and #{team_label(teams.second)}" if teams.size == 2
|
||||
return "with #{team_label(teams.first)}" if teams.size == 1
|
||||
|
||||
'without assigned teams'
|
||||
end
|
||||
|
||||
def team_label(team)
|
||||
return 'unknown team' if team.nil?
|
||||
|
||||
"#{team.name}(#{team.id})"
|
||||
end
|
||||
|
||||
def team_list_label(teams)
|
||||
teams.map { |team| team_label(team) }.join(', ')
|
||||
end
|
||||
|
||||
def blocking_ties_label(blocking_ties)
|
||||
blocking_ties.map do |tie|
|
||||
names = tie.fetch(:team_names, [])
|
||||
ids = tie.fetch(:team_ids, [])
|
||||
names.zip(ids).map { |name, id| "#{name}(#{id})" }.join(' vs ')
|
||||
end.join(', ')
|
||||
end
|
||||
|
||||
def group_scores_label(group_scores)
|
||||
group_scores_metadata(group_scores).map do |score|
|
||||
"#{score.fetch(:team_name)}(#{score.fetch(:team_id)}) groupscore " \
|
||||
"#{score.fetch(:group_points)}-#{score.fetch(:scored_points)}-#{score.fetch(:received_points)} " \
|
||||
"pos #{score.fetch(:position)}"
|
||||
end.join(', ')
|
||||
end
|
||||
|
||||
def group_scores_metadata(group_scores)
|
||||
Array(group_scores).map do |score|
|
||||
score.reload if score.persisted?
|
||||
{
|
||||
id: score.id,
|
||||
team_id: score.team_id,
|
||||
team_name: score.team.name,
|
||||
group_points: score.group_points,
|
||||
scored_points: score.scored_points,
|
||||
received_points: score.received_points,
|
||||
position: score.position
|
||||
}
|
||||
end
|
||||
end
|
||||
|
||||
def match_metadata(match)
|
||||
{
|
||||
match_id: match.id,
|
||||
decider: match.decider?,
|
||||
group_id: match.group_id,
|
||||
stage_id: match.stage_id,
|
||||
team_ids: match.teams.map(&:id),
|
||||
teams: teams_metadata(match.teams)
|
||||
}
|
||||
end
|
||||
|
||||
def teams_metadata(teams)
|
||||
teams.map do |team|
|
||||
{
|
||||
id: team.id,
|
||||
name: team.name
|
||||
}
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
|
|
@ -36,6 +36,7 @@ module TurniereBackend
|
|||
config.api_only = true
|
||||
config.action_cable.disable_request_forgery_protection = true
|
||||
config.action_cable.allowed_request_origins = [%r{\Ahttps?://.*\z}]
|
||||
config.active_storage.variant_processor = :disabled
|
||||
|
||||
config.middleware.insert_before 0, Rack::Cors do
|
||||
allow do
|
||||
|
|
@ -49,8 +50,5 @@ module TurniereBackend
|
|||
config.action_mailer.default_options = {
|
||||
from: 'noreply@turnie.re'
|
||||
}
|
||||
|
||||
# Active Record
|
||||
config.active_record.legacy_connection_handling = false
|
||||
end
|
||||
end
|
||||
|
|
|
|||
|
|
@ -6,7 +6,7 @@
|
|||
#
|
||||
default: &default
|
||||
adapter: sqlite3
|
||||
pool: <%= ENV.fetch("RAILS_MAX_THREADS") { 5 } %>
|
||||
pool: <%= ENV.fetch("RAILS_DB_POOL", ENV.fetch("RAILS_MAX_THREADS", 5)) %>
|
||||
timeout: 5000
|
||||
|
||||
development:
|
||||
|
|
@ -21,10 +21,12 @@ test:
|
|||
database: db/test.sqlite3
|
||||
|
||||
production: &production
|
||||
url: <%= ENV.fetch("POSTGRES_URL", nil) %>
|
||||
adapter: postgresql
|
||||
encoding: unicode
|
||||
url: <%= ENV["DATABASE_URL"] || ENV["POSTGRES_URL"] %>
|
||||
username: <%= ENV.fetch("POSTGRES_USERNAME", nil) %>
|
||||
password: <%= ENV.fetch("POSTGRES_PASSWORD", nil) %>
|
||||
pool: <%= ENV.fetch("RAILS_MAX_THREADS") { 5 } %>
|
||||
pool: <%= ENV.fetch("RAILS_DB_POOL", ENV.fetch("RAILS_MAX_THREADS", 5)) %>
|
||||
|
||||
beta:
|
||||
# just use the production settings
|
||||
|
|
|
|||
|
|
@ -79,12 +79,16 @@ Rails.application.configure do
|
|||
# Mail
|
||||
config.action_mailer.raise_delivery_errors = true
|
||||
config.action_mailer.default_url_options = { host: 'api.turnie.re' }
|
||||
if ENV['TURNIERE_DISABLE_EMAIL_DELIVERY'].present?
|
||||
config.action_mailer.perform_deliveries = false
|
||||
else
|
||||
config.action_mailer.delivery_method = :mailgun
|
||||
config.action_mailer.mailgun_settings = {
|
||||
api_key: ENV.fetch('MAILGUN_API_KEY'),
|
||||
domain: ENV.fetch('MAILGUN_DOMAIN'),
|
||||
api_host: 'api.eu.mailgun.net' # Uncomment this line for EU region domains
|
||||
}
|
||||
end
|
||||
|
||||
# Logging to STDOUT (running in docker by default)
|
||||
config.logger = Logger.new($stdout)
|
||||
|
|
|
|||
|
|
@ -0,0 +1,53 @@
|
|||
# frozen_string_literal: true
|
||||
|
||||
# Devise 4.9 still passes route option hashes to Rails' `resource` helper
|
||||
# positionally. Rails 8.1 accepts that with deprecation warnings and Rails 8.2
|
||||
# removes it, so keep the same Devise routes but pass those options as keywords.
|
||||
module DeviseRoutesRails8Compat
|
||||
def devise_session(mapping, controllers)
|
||||
resource :session, only: [], controller: controllers[:sessions], path: '' do
|
||||
get :new, path: mapping.path_names[:sign_in], as: 'new'
|
||||
post :create, path: mapping.path_names[:sign_in]
|
||||
match :destroy, path: mapping.path_names[:sign_out], as: 'destroy', via: mapping.sign_out_via
|
||||
end
|
||||
end
|
||||
|
||||
def devise_password(mapping, controllers)
|
||||
resource :password,
|
||||
only: %i[new create edit update],
|
||||
path: mapping.path_names[:password],
|
||||
controller: controllers[:passwords]
|
||||
end
|
||||
|
||||
def devise_confirmation(mapping, controllers)
|
||||
resource :confirmation,
|
||||
only: %i[new create show],
|
||||
path: mapping.path_names[:confirmation],
|
||||
controller: controllers[:confirmations]
|
||||
end
|
||||
|
||||
def devise_unlock(mapping, controllers)
|
||||
return unless mapping.to.unlock_strategy_enabled?(:email)
|
||||
|
||||
resource :unlock,
|
||||
only: %i[new create show],
|
||||
path: mapping.path_names[:unlock],
|
||||
controller: controllers[:unlocks]
|
||||
end
|
||||
|
||||
def devise_registration(mapping, controllers)
|
||||
resource :registration,
|
||||
only: %i[new create edit update destroy],
|
||||
path: mapping.path_names[:registration],
|
||||
path_names: {
|
||||
new: mapping.path_names[:sign_up],
|
||||
edit: mapping.path_names[:edit],
|
||||
cancel: mapping.path_names[:cancel]
|
||||
},
|
||||
controller: controllers[:registrations] do
|
||||
get :cancel
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
ActionDispatch::Routing::Mapper.prepend(DeviseRoutesRails8Compat)
|
||||
|
|
@ -48,5 +48,6 @@ DeviseTokenAuth.setup do |config|
|
|||
# do so by enabling this flag. NOTE: This feature is highly experimental!
|
||||
# config.enable_standard_devise_support = false
|
||||
|
||||
config.bypass_sign_in = false
|
||||
config.default_confirm_success_url = 'https://turnie.re'
|
||||
end
|
||||
|
|
|
|||
|
|
@ -6,8 +6,9 @@
|
|||
# the maximum value specified for Puma. Default is set to 5 threads for minimum
|
||||
# and maximum; this matches the default thread size of Active Record.
|
||||
#
|
||||
threads_count = ENV.fetch('RAILS_MAX_THREADS', 5)
|
||||
threads threads_count, threads_count
|
||||
max_threads_count = ENV.fetch('RAILS_MAX_THREADS', 5)
|
||||
min_threads_count = ENV.fetch('RAILS_MIN_THREADS', max_threads_count)
|
||||
threads min_threads_count, max_threads_count
|
||||
|
||||
# Specifies the `port` that Puma will listen on to receive requests; default is 3000.
|
||||
#
|
||||
|
|
@ -15,7 +16,7 @@ port ENV.fetch('PORT', 3000)
|
|||
|
||||
# Specifies the `environment` that Puma will run in.
|
||||
#
|
||||
environment ENV.fetch('RAILS_ENV', 'development')
|
||||
environment ENV.fetch('RAILS_ENV', 'production')
|
||||
|
||||
# Specifies the number of `workers` to boot in clustered mode.
|
||||
# Workers are forked webserver processes. If using threads and workers together
|
||||
|
|
@ -23,14 +24,14 @@ environment ENV.fetch('RAILS_ENV', 'development')
|
|||
# Workers do not work on JRuby or Windows (both of which do not support
|
||||
# processes).
|
||||
#
|
||||
# workers ENV.fetch("WEB_CONCURRENCY") { 2 }
|
||||
workers ENV.fetch('WEB_CONCURRENCY', 2)
|
||||
|
||||
# Use the `preload_app!` method when specifying a `workers` number.
|
||||
# This directive tells Puma to first boot the application and load code
|
||||
# before forking the application. This takes advantage of Copy On Write
|
||||
# process behavior so workers use less memory.
|
||||
#
|
||||
# preload_app!
|
||||
preload_app!
|
||||
|
||||
# Allow puma to be restarted by `rails restart` command.
|
||||
plugin :tmp_restart
|
||||
|
|
|
|||
|
|
@ -19,12 +19,17 @@ Rails.application.routes.draw do
|
|||
end
|
||||
resources :stages, only: %i[show update]
|
||||
resources :teams, only: %i[show update]
|
||||
resources :accounts, only: %i[show]
|
||||
resources :team_action_items, only: %i[update]
|
||||
resources :tournaments do
|
||||
resources :team_action_lists, only: %i[create]
|
||||
patch 'team_action_lists/:key/teams/:team_id', to: 'team_action_lists#update_item'
|
||||
resources :statistics, only: %i[index]
|
||||
resources :matches, only: %i[index]
|
||||
resources :matches, only: %i[index] do
|
||||
collection do
|
||||
patch :metadata
|
||||
end
|
||||
end
|
||||
resources :beamers, only: %i[index show create update destroy] do
|
||||
collection do
|
||||
get :by_name
|
||||
|
|
@ -32,12 +37,16 @@ Rails.application.routes.draw do
|
|||
end
|
||||
member do
|
||||
get :timer
|
||||
get :transaction_log, to: 'tournament_transaction_log_entries#index'
|
||||
patch :timer, action: :set_timer
|
||||
patch :sync_state
|
||||
patch :start_next_group_matches
|
||||
post :test_sync
|
||||
delete :playoffs, action: :destroy_playoffs
|
||||
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
|
||||
|
|
|
|||
|
|
@ -0,0 +1,2 @@
|
|||
---
|
||||
:concurrency: <%= ENV.fetch("SIDEKIQ_CONCURRENCY", 5) %>
|
||||
|
|
@ -0,0 +1,8 @@
|
|||
# frozen_string_literal: true
|
||||
|
||||
class AddTimerReasonToTournaments < ActiveRecord::Migration[7.0]
|
||||
def change
|
||||
add_column :tournaments, :timer_reason, :string
|
||||
add_column :tournaments, :timer_reason_text, :text
|
||||
end
|
||||
end
|
||||
|
|
@ -0,0 +1,34 @@
|
|||
# frozen_string_literal: true
|
||||
|
||||
require 'openssl'
|
||||
|
||||
class AddSyncAuthTokenDigestToTournaments < ActiveRecord::Migration[7.0]
|
||||
class MigrationTournament < ActiveRecord::Base
|
||||
self.table_name = 'tournaments'
|
||||
end
|
||||
|
||||
def up
|
||||
add_column :tournaments, :sync_auth_token_digest, :string unless column_exists?(:tournaments, :sync_auth_token_digest)
|
||||
|
||||
MigrationTournament.reset_column_information
|
||||
MigrationTournament
|
||||
.where(read_only_mode: true)
|
||||
.where.not(sync_auth_token: [nil, ''])
|
||||
.find_each do |tournament|
|
||||
tournament.update!(
|
||||
sync_auth_token: nil,
|
||||
sync_auth_token_digest: sync_auth_token_digest(tournament.sync_auth_token)
|
||||
)
|
||||
end
|
||||
end
|
||||
|
||||
def down
|
||||
remove_column :tournaments, :sync_auth_token_digest if column_exists?(:tournaments, :sync_auth_token_digest)
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def sync_auth_token_digest(token)
|
||||
OpenSSL::HMAC.hexdigest('SHA256', Rails.application.secret_key_base, token.to_s)
|
||||
end
|
||||
end
|
||||
|
|
@ -0,0 +1,21 @@
|
|||
# frozen_string_literal: true
|
||||
|
||||
class CreateTournamentTransactionLogEntries < ActiveRecord::Migration[7.0]
|
||||
def change
|
||||
create_table :tournament_transaction_log_entries do |t|
|
||||
t.references :tournament, null: false, foreign_key: { on_delete: :cascade }, index: true
|
||||
t.references :user, foreign_key: { on_delete: :nullify }, index: true
|
||||
t.integer :sequence, null: false
|
||||
t.string :action, null: false
|
||||
t.text :message, null: false
|
||||
t.json :metadata, null: false, default: {}
|
||||
|
||||
t.timestamps
|
||||
end
|
||||
|
||||
add_index :tournament_transaction_log_entries,
|
||||
%i[tournament_id sequence],
|
||||
unique: true,
|
||||
name: 'index_tournament_log_entries_on_tournament_and_sequence'
|
||||
end
|
||||
end
|
||||
|
|
@ -0,0 +1,12 @@
|
|||
# frozen_string_literal: true
|
||||
|
||||
class AddMetadataToTournaments < ActiveRecord::Migration[7.0]
|
||||
def change
|
||||
change_table :tournaments, bulk: true do |t|
|
||||
t.string :location
|
||||
t.datetime :starts_at
|
||||
t.datetime :ends_at
|
||||
t.string :website_url
|
||||
end
|
||||
end
|
||||
end
|
||||
|
|
@ -0,0 +1,9 @@
|
|||
# frozen_string_literal: true
|
||||
|
||||
class AddMatchMetadataToMatches < ActiveRecord::Migration[7.0]
|
||||
def change
|
||||
add_column :matches, :location, :string
|
||||
add_column :matches, :start_time, :string
|
||||
add_column :matches, :notes, :text
|
||||
end
|
||||
end
|
||||
|
|
@ -0,0 +1,18 @@
|
|||
# frozen_string_literal: true
|
||||
|
||||
class CreateTeamPlayers < ActiveRecord::Migration[7.0]
|
||||
def change
|
||||
create_table :team_players do |t|
|
||||
t.references :team, null: false, type: :integer, foreign_key: { on_delete: :cascade }, index: true
|
||||
t.references :player,
|
||||
null: false,
|
||||
type: :integer,
|
||||
foreign_key: { to_table: :users, on_delete: :cascade },
|
||||
index: true
|
||||
|
||||
t.timestamps
|
||||
end
|
||||
|
||||
add_index :team_players, %i[team_id player_id], unique: true
|
||||
end
|
||||
end
|
||||
184
db/schema.rb
184
db/schema.rb
|
|
@ -10,96 +10,99 @@
|
|||
#
|
||||
# It's strongly recommended that you check this file into your version control system.
|
||||
|
||||
ActiveRecord::Schema[7.0].define(version: 2026_04_26_121000) do
|
||||
ActiveRecord::Schema[8.1].define(version: 2026_05_04_120000) do
|
||||
create_table "beamers", force: :cascade do |t|
|
||||
t.string "name", null: false
|
||||
t.json "config", default: {}, null: false
|
||||
t.datetime "created_at", null: false
|
||||
t.string "display_state", default: "during_game", null: false
|
||||
t.boolean "is_default", default: false
|
||||
t.integer "tournament_id", null: false
|
||||
t.datetime "created_at", null: false
|
||||
t.datetime "updated_at", null: false
|
||||
t.string "name", null: false
|
||||
t.integer "sync_source_id"
|
||||
t.json "config", default: {}, null: false
|
||||
t.integer "tournament_id", null: false
|
||||
t.datetime "updated_at", null: false
|
||||
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.datetime "created_at", precision: nil, 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.integer "user_id", 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 "group_id", null: false
|
||||
t.integer "group_points", default: 0
|
||||
t.integer "position", default: 0
|
||||
t.integer "received_points", default: 0
|
||||
t.integer "scored_points", default: 0
|
||||
t.integer "sync_source_id"
|
||||
t.integer "team_id", null: false
|
||||
t.datetime "updated_at", precision: nil, null: false
|
||||
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.datetime "created_at", precision: nil, null: false
|
||||
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.datetime "updated_at", precision: nil, null: false
|
||||
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 "match_id", null: false
|
||||
t.integer "points", default: 0
|
||||
t.integer "sync_source_id"
|
||||
t.integer "team_id", null: false
|
||||
t.datetime "updated_at", precision: nil, null: false
|
||||
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.datetime "created_at", precision: nil, null: false
|
||||
t.boolean "decider", default: false, null: false
|
||||
t.integer "group_id"
|
||||
t.string "location"
|
||||
t.text "notes"
|
||||
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.string "start_time"
|
||||
t.integer "state", default: 0
|
||||
t.integer "sync_source_id"
|
||||
t.datetime "updated_at", precision: nil, null: false
|
||||
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.datetime "created_at", precision: nil, null: false
|
||||
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.integer "tournament_id", null: false
|
||||
t.datetime "updated_at", precision: nil, null: false
|
||||
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.integer "sync_source_id"
|
||||
t.integer "team_action_list_id", null: false
|
||||
t.integer "team_id", 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
|
||||
|
|
@ -108,91 +111,122 @@ ActiveRecord::Schema[7.0].define(version: 2026_04_26_121000) do
|
|||
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.datetime "created_at", null: false
|
||||
t.string "key", null: false
|
||||
t.string "name", null: false
|
||||
t.string "source", null: false
|
||||
t.integer "sync_source_id"
|
||||
t.datetime "created_at", null: false
|
||||
t.integer "tournament_id", 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 "team_players", force: :cascade do |t|
|
||||
t.datetime "created_at", null: false
|
||||
t.integer "player_id", null: false
|
||||
t.integer "team_id", null: false
|
||||
t.datetime "updated_at", null: false
|
||||
t.index ["player_id"], name: "index_team_players_on_player_id"
|
||||
t.index ["team_id", "player_id"], name: "index_team_players_on_team_id_and_player_id", unique: true
|
||||
t.index ["team_id"], name: "index_team_players_on_team_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.string "name"
|
||||
t.integer "sync_source_id"
|
||||
t.integer "tournament_id"
|
||||
t.datetime "updated_at", precision: nil, null: false
|
||||
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 "last_attempt_at"
|
||||
t.string "last_error"
|
||||
t.string "lock_token"
|
||||
t.datetime "locked_at"
|
||||
t.datetime "next_attempt_at", null: false
|
||||
t.text "snapshot_json", null: false
|
||||
t.string "status", default: "pending", null: false
|
||||
t.integer "tournament_id", 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 "tournament_transaction_log_entries", force: :cascade do |t|
|
||||
t.string "action", null: false
|
||||
t.datetime "created_at", null: false
|
||||
t.text "message", null: false
|
||||
t.json "metadata", default: {}, null: false
|
||||
t.integer "sequence", null: false
|
||||
t.integer "tournament_id", null: false
|
||||
t.datetime "updated_at", null: false
|
||||
t.integer "user_id"
|
||||
t.index ["tournament_id", "sequence"], name: "index_tournament_log_entries_on_tournament_and_sequence", unique: true
|
||||
t.index ["tournament_id"], name: "index_tournament_transaction_log_entries_on_tournament_id"
|
||||
t.index ["user_id"], name: "index_tournament_transaction_log_entries_on_user_id"
|
||||
end
|
||||
|
||||
create_table "tournaments", force: :cascade do |t|
|
||||
t.string "name", null: false
|
||||
t.string "code", null: false
|
||||
t.datetime "created_at", precision: nil, null: false
|
||||
t.string "description"
|
||||
t.boolean "public", default: true
|
||||
t.integer "playoff_teams_amount", default: 0
|
||||
t.datetime "ends_at"
|
||||
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.string "location"
|
||||
t.string "name", null: false
|
||||
t.integer "playoff_teams_amount", default: 0
|
||||
t.boolean "public", default: true
|
||||
t.boolean "read_only_mode", default: false, null: false
|
||||
t.string "sync_target_url"
|
||||
t.datetime "starts_at"
|
||||
t.string "sync_auth_token"
|
||||
t.integer "sync_source_tournament_id"
|
||||
t.datetime "sync_last_pushed_at"
|
||||
t.string "sync_last_push_error"
|
||||
t.string "sync_auth_token_digest"
|
||||
t.datetime "sync_last_imported_snapshot_at"
|
||||
t.string "sync_last_push_error"
|
||||
t.datetime "sync_last_pushed_at"
|
||||
t.integer "sync_source_tournament_id"
|
||||
t.string "sync_target_url"
|
||||
t.string "timer_mode"
|
||||
t.string "timer_reason"
|
||||
t.text "timer_reason_text"
|
||||
t.datetime "timestamp"
|
||||
t.datetime "updated_at", precision: nil, null: false
|
||||
t.integer "user_id", null: false
|
||||
t.string "website_url"
|
||||
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.datetime "confirmation_sent_at", precision: nil
|
||||
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 "current_sign_in_at", precision: nil
|
||||
t.string "current_sign_in_ip"
|
||||
t.string "email"
|
||||
t.string "encrypted_password", default: "", null: false
|
||||
t.datetime "last_sign_in_at", precision: nil
|
||||
t.string "last_sign_in_ip"
|
||||
t.string "provider", default: "email", null: false
|
||||
t.datetime "remember_created_at", precision: nil
|
||||
t.datetime "reset_password_sent_at", precision: nil
|
||||
t.string "reset_password_token"
|
||||
t.integer "sign_in_count", default: 0, null: false
|
||||
t.text "tokens"
|
||||
t.string "uid", default: "", null: false
|
||||
t.string "unconfirmed_email"
|
||||
t.datetime "updated_at", precision: nil, null: false
|
||||
t.string "username"
|
||||
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"
|
||||
|
|
@ -216,7 +250,11 @@ ActiveRecord::Schema[7.0].define(version: 2026_04_26_121000) do
|
|||
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 "team_players", "teams", on_delete: :cascade
|
||||
add_foreign_key "team_players", "users", column: "player_id", 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 "tournament_transaction_log_entries", "tournaments", on_delete: :cascade
|
||||
add_foreign_key "tournament_transaction_log_entries", "users", on_delete: :nullify
|
||||
add_foreign_key "tournaments", "users", on_delete: :cascade
|
||||
end
|
||||
|
|
|
|||
10
db/seeds.rb
10
db/seeds.rb
|
|
@ -1,7 +1,13 @@
|
|||
# frozen_string_literal: true
|
||||
|
||||
if Rails.env.production? && ENV.fetch('TURNIERE_ENABLE_DEMO_SEEDS', nil) != '1'
|
||||
warn 'Skipping demo seeds in production. Set TURNIERE_ENABLE_DEMO_SEEDS=1 to force them.'
|
||||
else
|
||||
begin
|
||||
require 'faker'
|
||||
|
||||
rescue LoadError
|
||||
warn 'Skipping demo seeds because faker is unavailable.'
|
||||
else
|
||||
user = User.create! username: Faker::Internet.username, email: Faker::Internet.email, password: Faker::Internet.password
|
||||
tournament = user.tournaments.create! name: Faker::Creature::Dog.name
|
||||
|
||||
|
|
@ -24,3 +30,5 @@ end
|
|||
end
|
||||
stage.save!
|
||||
end
|
||||
end
|
||||
end
|
||||
|
|
|
|||
|
|
@ -0,0 +1,350 @@
|
|||
# Account Profiles And Team Players API
|
||||
|
||||
This API lets public pages show player account profiles and lets tournament
|
||||
owners associate one or more player accounts with teams.
|
||||
|
||||
Terminology:
|
||||
|
||||
- account: existing `User` record
|
||||
- player account: user account associated with one or more tournament teams
|
||||
- tournament owner: organizer account that owns the tournament
|
||||
- team player: account linked to a team through the `players` array
|
||||
|
||||
There is no separate organizer-only "create player" endpoint. Organizers create
|
||||
or reuse player accounts by sending player email values through the tournament
|
||||
or team endpoints below.
|
||||
|
||||
## Public Account Profile
|
||||
|
||||
Use this for public-facing player profile pages.
|
||||
|
||||
Endpoint:
|
||||
|
||||
- `GET /accounts/:id`
|
||||
|
||||
Authentication:
|
||||
|
||||
- optional
|
||||
- anonymous callers see only public tournament/team profile data
|
||||
- the account owner sees their own private tournament/team profile data too
|
||||
|
||||
Example response:
|
||||
|
||||
```json
|
||||
{
|
||||
"id": 42,
|
||||
"name": "alice",
|
||||
"username": "alice",
|
||||
"tournaments": [
|
||||
{
|
||||
"id": 11,
|
||||
"name": "Summer Cup",
|
||||
"code": "a1b2c3",
|
||||
"public": true
|
||||
}
|
||||
],
|
||||
"created_tournaments": [
|
||||
{
|
||||
"id": 12,
|
||||
"name": "Alice Open",
|
||||
"code": "d4e5f6",
|
||||
"public": true
|
||||
}
|
||||
],
|
||||
"teams": [
|
||||
{
|
||||
"id": 99,
|
||||
"name": "Team Alpha",
|
||||
"tournament": {
|
||||
"id": 11,
|
||||
"name": "Summer Cup",
|
||||
"code": "a1b2c3",
|
||||
"public": true
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
Response fields:
|
||||
|
||||
- `id`: account id
|
||||
- `name`: same value as `username`, for public display
|
||||
- `username`: account username
|
||||
- `tournaments`: tournaments where this account is linked to at least one team
|
||||
- `created_tournaments`: tournaments created by this account
|
||||
- `teams`: teams linked to this account
|
||||
|
||||
Visibility:
|
||||
|
||||
- anonymous/profile visitors see only public tournaments and teams in public tournaments
|
||||
- authenticated profile owner sees public and private tournaments/teams for their own account
|
||||
|
||||
Errors:
|
||||
|
||||
- `404` when account id does not exist
|
||||
|
||||
## Team Read Payload
|
||||
|
||||
Teams expose linked player accounts in a `players` array.
|
||||
|
||||
Endpoint:
|
||||
|
||||
- `GET /teams/:id`
|
||||
|
||||
Example response:
|
||||
|
||||
```json
|
||||
{
|
||||
"id": 99,
|
||||
"name": "Team Alpha",
|
||||
"players": [
|
||||
{
|
||||
"id": 42,
|
||||
"name": "alice",
|
||||
"username": "alice"
|
||||
},
|
||||
{
|
||||
"id": 43,
|
||||
"name": "bob",
|
||||
"username": "bob"
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
Empty team-player association:
|
||||
|
||||
```json
|
||||
{
|
||||
"id": 99,
|
||||
"name": "Team Alpha",
|
||||
"players": []
|
||||
}
|
||||
```
|
||||
|
||||
## Tournament Read Payload
|
||||
|
||||
Full tournament reads include the same player summaries on each team.
|
||||
|
||||
Endpoint:
|
||||
|
||||
- `GET /tournaments/:id`
|
||||
|
||||
Relevant response fragment:
|
||||
|
||||
```json
|
||||
{
|
||||
"id": 11,
|
||||
"name": "Summer Cup",
|
||||
"teams": [
|
||||
{
|
||||
"id": 99,
|
||||
"name": "Team Alpha",
|
||||
"players": [
|
||||
{
|
||||
"id": 42,
|
||||
"name": "alice",
|
||||
"username": "alice"
|
||||
}
|
||||
],
|
||||
"advancing_from_group_stage": false
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
`GET /tournaments/:id?simple=true` does not include teams.
|
||||
|
||||
## Link Players While Creating Teams
|
||||
|
||||
Tournament creation accepts player email values inside new team objects.
|
||||
|
||||
Endpoint:
|
||||
|
||||
- `POST /tournaments`
|
||||
|
||||
Authentication:
|
||||
|
||||
- tournament owner account required
|
||||
|
||||
Single player:
|
||||
|
||||
```json
|
||||
{
|
||||
"name": "Summer Cup",
|
||||
"public": true,
|
||||
"teams": [
|
||||
{
|
||||
"name": "Team Alpha",
|
||||
"player_email": "alice@example.com"
|
||||
},
|
||||
{
|
||||
"name": "Team Beta"
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
Multiple players:
|
||||
|
||||
```json
|
||||
{
|
||||
"name": "Summer Cup",
|
||||
"public": true,
|
||||
"teams": [
|
||||
{
|
||||
"name": "Team Alpha",
|
||||
"player_emails": [
|
||||
"alice@example.com",
|
||||
"bob@example.com"
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
Behavior:
|
||||
|
||||
- existing accounts are reused by case-insensitive email match
|
||||
- missing accounts are created by email
|
||||
- no password or token is returned for organizer-created accounts
|
||||
- teams without player email values remain unlinked
|
||||
- existing teams passed by `id` keep their existing player associations
|
||||
|
||||
Group-stage creation uses the same team object fields:
|
||||
|
||||
```json
|
||||
{
|
||||
"name": "Summer Cup",
|
||||
"public": true,
|
||||
"group_stage": true,
|
||||
"playoff_teams_amount": 8,
|
||||
"teams": [
|
||||
{
|
||||
"name": "Team Alpha",
|
||||
"group": 0,
|
||||
"player_emails": ["alice@example.com", "bob@example.com"]
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
## Link Or Replace Players On Existing Teams
|
||||
|
||||
Endpoint:
|
||||
|
||||
- `PATCH /teams/:id`
|
||||
- `PUT /teams/:id`
|
||||
|
||||
Authentication:
|
||||
|
||||
- tournament owner may rename the team and manage player links
|
||||
- linked player may rename their own team
|
||||
- linked player may not change player links
|
||||
- other authenticated users receive `403`
|
||||
- read-only follower tournaments receive `423`
|
||||
|
||||
### Rename Team
|
||||
|
||||
```json
|
||||
{
|
||||
"name": "Team Alpha Renamed"
|
||||
}
|
||||
```
|
||||
|
||||
### Add One Player By Email
|
||||
|
||||
```json
|
||||
{
|
||||
"player_email": "alice@example.com"
|
||||
}
|
||||
```
|
||||
|
||||
Behavior:
|
||||
|
||||
- existing account is reused
|
||||
- missing account is created
|
||||
- player is appended to the team if not already linked
|
||||
- existing team players stay linked
|
||||
|
||||
### Add One Player By Id
|
||||
|
||||
```json
|
||||
{
|
||||
"player_id": 42
|
||||
}
|
||||
```
|
||||
|
||||
Behavior:
|
||||
|
||||
- player is appended to the team if not already linked
|
||||
- existing team players stay linked
|
||||
- unknown id returns `404`
|
||||
|
||||
### Replace All Players By Email
|
||||
|
||||
```json
|
||||
{
|
||||
"player_emails": [
|
||||
"alice@example.com",
|
||||
"bob@example.com"
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
Behavior:
|
||||
|
||||
- replaces the full `players` list for the team
|
||||
- empty array clears all players
|
||||
- blank email values are ignored
|
||||
- missing accounts are created
|
||||
|
||||
### Replace All Players By Id
|
||||
|
||||
```json
|
||||
{
|
||||
"player_ids": [42, 43]
|
||||
}
|
||||
```
|
||||
|
||||
Behavior:
|
||||
|
||||
- replaces the full `players` list for the team
|
||||
- empty array clears all players
|
||||
- unknown ids return `404`
|
||||
|
||||
### Clear All Players
|
||||
|
||||
Both single-value forms clear all players when sent blank:
|
||||
|
||||
```json
|
||||
{
|
||||
"player_email": ""
|
||||
}
|
||||
```
|
||||
|
||||
```json
|
||||
{
|
||||
"player_id": ""
|
||||
}
|
||||
```
|
||||
|
||||
## Error Handling
|
||||
|
||||
Expected statuses:
|
||||
|
||||
- `200` team/account read or team update accepted
|
||||
- `201` tournament created
|
||||
- `401` authentication required
|
||||
- `403` authenticated user cannot change this team/tournament
|
||||
- `404` account, team, tournament, or player id not found
|
||||
- `422` invalid create/update payload
|
||||
- `423` follower tournament is read-only
|
||||
|
||||
## Sync Behavior
|
||||
|
||||
Team-player account links are not part of leader/follower tournament sync
|
||||
snapshots. Follower tournaments still expose teams, match data, and other synced
|
||||
tournament data, but account ownership links are managed on the writable source
|
||||
side.
|
||||
|
|
@ -24,6 +24,7 @@ Create empty tournament with:
|
|||
- `sync_auth_token: <shared secret>`
|
||||
|
||||
Do not send `teams` payload for follower. Backend allows empty read only follower tournament creation.
|
||||
The token is write-only. Backend responses never return it; owner responses only expose `sync_auth_configured`.
|
||||
|
||||
### 2. Create normal leader tournament on local backend
|
||||
|
||||
|
|
@ -178,9 +179,9 @@ Owner-facing frontend can expose:
|
|||
|
||||
Recommended owner UX:
|
||||
|
||||
1. create remote follower first
|
||||
2. copy remote `sync_auth_token`
|
||||
3. paste remote `sync_state` URL into leader
|
||||
1. generate a shared token outside the backend
|
||||
2. create remote follower with that `sync_auth_token`
|
||||
3. paste same token and remote `sync_state` URL into leader
|
||||
4. save leader sync config
|
||||
5. show last push time / error state
|
||||
6. allow explicit follower takeover by disabling `read_only_mode`
|
||||
|
|
|
|||
|
|
@ -0,0 +1,39 @@
|
|||
# Load Tests
|
||||
|
||||
Load smoke tests live in `e2e/load_test.rb` and run through invoke tasks.
|
||||
|
||||
The runner creates a realistic group-stage tournament over HTTP, then measures:
|
||||
|
||||
- concurrent `GET /tournaments/:id` reads at each configured client level
|
||||
- active ActionCable subscriptions to `TournamentChannel`, opened in bounded
|
||||
batches so the test measures 200 active sockets instead of only handshake
|
||||
burst capacity
|
||||
- `/metrics` websocket gauges and process counters after the run
|
||||
|
||||
It prints relative timings such as p50, p95, max, and requests per second. The
|
||||
CI goal is not a fixed benchmark. The job fails when requests error, websocket
|
||||
clients cannot subscribe, or ActionCable connection metrics do not reflect the
|
||||
opened clients.
|
||||
|
||||
Useful commands:
|
||||
|
||||
- `inv verify-load-test`
|
||||
- `inv load-test --base-url=http://127.0.0.1:3000`
|
||||
- `inv load-test --http-clients=1,10,50,100 --websocket-clients=1,10,50,100,200`
|
||||
- `inv blackbox-load-service`
|
||||
|
||||
Useful environment overrides:
|
||||
|
||||
- `TURNIERE_LOAD_HTTP_CLIENTS=1,10,50,100`
|
||||
- `TURNIERE_LOAD_WEBSOCKET_CLIENTS=1,10,50,100,200`
|
||||
- `TURNIERE_LOAD_HTTP_REQUESTS_PER_CLIENT=3`
|
||||
- `TURNIERE_LOAD_WEBSOCKET_HOLD_SECONDS=1.0`
|
||||
- `TURNIERE_LOAD_WEBSOCKET_CONNECT_BATCH_SIZE=25`
|
||||
- `TURNIERE_LOAD_GROUP_COUNT=4`
|
||||
- `TURNIERE_LOAD_TEAMS_PER_GROUP=4`
|
||||
- `TURNIERE_LOAD_PLAYOFF_TEAMS=8`
|
||||
- `TURNIERE_LOAD_FORMAT=json`
|
||||
|
||||
GitLab CI runs `rails load smoke` against the production image with Postgres,
|
||||
covering read pressure up to 100 HTTP clients and active websocket pressure up
|
||||
to 200 clients.
|
||||
|
|
@ -0,0 +1,86 @@
|
|||
# Match Metadata API
|
||||
|
||||
Matches can store optional scheduling/display metadata:
|
||||
|
||||
- `location`: string, for venue, court, stream, or table
|
||||
- `start_time`: string, for a local time like `17:00` or an agreed display value
|
||||
- `notes`: free text for one-off match information
|
||||
|
||||
All fields are optional and nullable. Omitted fields are left unchanged.
|
||||
|
||||
## Update One Match
|
||||
|
||||
Use `PATCH /matches/:id` to update metadata on one match. This endpoint still
|
||||
accepts the existing `state` transitions, and metadata can be sent with or
|
||||
without `state`.
|
||||
|
||||
```json
|
||||
{
|
||||
"location": "Main Hall",
|
||||
"start_time": "17:00",
|
||||
"notes": "Opening match. Stream setup needed."
|
||||
}
|
||||
```
|
||||
|
||||
Owner authentication is required. Read-only follower tournaments reject this
|
||||
with `423 Locked`.
|
||||
|
||||
## Bulk Update Matches
|
||||
|
||||
Use `PATCH /tournaments/:tournament_id/matches/metadata` to update metadata for
|
||||
multiple matches in one tournament.
|
||||
|
||||
At least one metadata field is required:
|
||||
|
||||
```json
|
||||
{
|
||||
"start_time": "18:00"
|
||||
}
|
||||
```
|
||||
|
||||
At least one selector is required:
|
||||
|
||||
- `match_ids`: explicit match ids in this tournament
|
||||
- `group_stage_position`: all group stage matches at that match position
|
||||
- `stage_level`: all playoff matches in that stage level
|
||||
- `stage_id`: all matches in one stage
|
||||
- `position`: all matches with that position
|
||||
- `state`: all matches in that state, including `upcoming`
|
||||
- `all: true`: every match in the tournament
|
||||
|
||||
Explicit ids take precedence over filters. Missing ids outside the tournament
|
||||
return `404 Not Found`.
|
||||
|
||||
Examples:
|
||||
|
||||
```json
|
||||
{
|
||||
"match_ids": [1, 2, 5],
|
||||
"start_time": "17:00"
|
||||
}
|
||||
```
|
||||
|
||||
```json
|
||||
{
|
||||
"group_stage_position": 1,
|
||||
"start_time": "18:00"
|
||||
}
|
||||
```
|
||||
|
||||
```json
|
||||
{
|
||||
"stage_level": 3,
|
||||
"location": "Finals Arena",
|
||||
"start_time": "21:00",
|
||||
"notes": "Playoff wave"
|
||||
}
|
||||
```
|
||||
|
||||
Owner authentication is required. Read-only follower tournaments reject this
|
||||
with `423 Locked`.
|
||||
|
||||
## Follower Sync
|
||||
|
||||
`location`, `start_time`, and `notes` are part of tournament sync snapshots.
|
||||
Leader updates enqueue follower sync and live tournament broadcasts. Followers
|
||||
import these fields from the leader and remain read-only until takeover.
|
||||
|
|
@ -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.
|
||||
|
|
@ -0,0 +1,64 @@
|
|||
# Rails 8 Dependency Update Notes
|
||||
|
||||
## Scope
|
||||
|
||||
This update moves the backend runtime to Ruby 4.0.3, Bundler 4.0.6, Rails 8.1,
|
||||
Puma 8, and the released `devise_token_auth` gem.
|
||||
|
||||
The project no longer uses the Thor77 `devise_token_auth` fork. The released
|
||||
gem currently supports this stack through `devise_token_auth` 1.2.6 with
|
||||
`devise` 4.9.4.
|
||||
|
||||
Because this is an API-only app without session middleware,
|
||||
`DeviseTokenAuth.bypass_sign_in` must stay disabled. With the gem default
|
||||
enabled, authenticated token requests call Devise session bypass code and fail
|
||||
in API-only production. Disabled mode still authenticates token requests with
|
||||
`store: false`.
|
||||
|
||||
## Docker Versioning
|
||||
|
||||
Container versioning still uses the shared pipeline `base_commit` build arg.
|
||||
The production and test images burn that value into `GIT_COMMIT_SHA` at build
|
||||
time.
|
||||
|
||||
Do not set `GIT_COMMIT_SHA` as a runtime service variable in CI or deployment.
|
||||
Runtime overrides can make `/version` report a different SHA than the image was
|
||||
built from.
|
||||
|
||||
## Email Delivery In Blackbox Runs
|
||||
|
||||
`TURNIERE_BLACKBOX_DISABLE_EMAIL_DELIVERY` is a local blackbox compose variable.
|
||||
It maps to the app runtime variable `TURNIERE_DISABLE_EMAIL_DELIVERY`.
|
||||
|
||||
Blackbox production E2E uses real production mode but fake Mailgun credentials.
|
||||
Registration sends a confirmation email, so blackbox runs disable delivery to
|
||||
avoid calling Mailgun while still keeping the production confirmation flow.
|
||||
|
||||
Normal production deployments should not set `TURNIERE_DISABLE_EMAIL_DELIVERY`
|
||||
unless email delivery is intentionally disabled.
|
||||
|
||||
## Schema Diff
|
||||
|
||||
Rails 8.1 dumps columns in a different order than Rails 7. That creates a large
|
||||
`db/schema.rb` diff, but it is ordering churn, not dropped columns.
|
||||
|
||||
Relative to current `master`, no existing schema columns are removed by the
|
||||
dependency update. The timer reason work on `master` adds:
|
||||
|
||||
- `tournaments.timer_reason`
|
||||
- `tournaments.timer_reason_text`
|
||||
|
||||
## API And Frontend Compatibility
|
||||
|
||||
Known frontend-facing API changes are additive:
|
||||
|
||||
- tournament payloads include `timer_reason`
|
||||
- tournament payloads include `timer_reason_text`
|
||||
- timer endpoints accept and return those same fields
|
||||
|
||||
Existing frontend code can ignore these fields and keep using `timestamp` and
|
||||
`timer_mode`. Frontend changes are only needed if the UI should show or edit
|
||||
timer reasons.
|
||||
|
||||
No deployment configuration change is required beyond building/running the new
|
||||
image with the existing shared pipeline build metadata.
|
||||
|
|
@ -15,6 +15,8 @@ Frontend should treat timer state as:
|
|||
- no timer: `timestamp = null`, `timer_mode = null`
|
||||
- countdown: `timer_mode = "countdown"`
|
||||
- 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
|
||||
|
||||
|
|
@ -31,7 +33,9 @@ Response shape:
|
|||
```json
|
||||
{
|
||||
"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 = "countup"`
|
||||
- `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`
|
||||
|
||||
|
|
@ -103,7 +114,8 @@ Request:
|
|||
|
||||
```json
|
||||
{
|
||||
"timestamp_seconds": 120
|
||||
"timestamp_seconds": 120,
|
||||
"timer_reason": "tournament_start"
|
||||
}
|
||||
```
|
||||
|
||||
|
|
@ -120,7 +132,8 @@ Request:
|
|||
|
||||
```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
|
||||
{
|
||||
"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
|
||||
{
|
||||
"timer_mode": "countup"
|
||||
"timer_mode": "countup",
|
||||
"timer_reason": "match_end"
|
||||
}
|
||||
```
|
||||
|
||||
|
|
@ -157,7 +173,9 @@ Backend also accepts explicit timestamp with countup mode:
|
|||
```json
|
||||
{
|
||||
"timestamp": "2026-04-24T10:00:00Z",
|
||||
"timer_mode": "countup"
|
||||
"timer_mode": "countup",
|
||||
"timer_reason": "custom",
|
||||
"timer_reason_text": "Manual overtime"
|
||||
}
|
||||
```
|
||||
|
||||
|
|
@ -167,6 +185,39 @@ Useful for:
|
|||
- rehydrating synced follower state
|
||||
- advanced admin tooling
|
||||
|
||||
### Change timer reason without restarting
|
||||
|
||||
After a timer is active, owner may change only the reason fields.
|
||||
Backend keeps current `timestamp` and `timer_mode` unchanged.
|
||||
|
||||
Set a predefined reason:
|
||||
|
||||
```json
|
||||
{
|
||||
"timer_reason": "playoff_start"
|
||||
}
|
||||
```
|
||||
|
||||
Set a custom reason:
|
||||
|
||||
```json
|
||||
{
|
||||
"timer_reason": "custom",
|
||||
"timer_reason_text": "Opening ceremony ran long"
|
||||
}
|
||||
```
|
||||
|
||||
Remove reason:
|
||||
|
||||
```json
|
||||
{
|
||||
"timer_reason": null
|
||||
}
|
||||
```
|
||||
|
||||
Response shape is the normal timer state with the same active `timestamp` and
|
||||
`timer_mode`, plus updated reason fields.
|
||||
|
||||
### Clear timer
|
||||
|
||||
Request:
|
||||
|
|
@ -179,10 +230,12 @@ Request:
|
|||
|
||||
Behavior:
|
||||
|
||||
- backend clears both fields
|
||||
- backend clears all timer fields
|
||||
- response returns:
|
||||
- `timestamp = null`
|
||||
- `timer_mode = null`
|
||||
- `timer_reason = null`
|
||||
- `timer_reason_text = null`
|
||||
|
||||
## Validation Rules
|
||||
|
||||
|
|
@ -194,6 +247,11 @@ Backend rejects:
|
|||
- `timestamp_seconds` together with `timer_mode = "countup"`
|
||||
- `timer_mode` by itself unless it is `countup`
|
||||
- `clear_timer` mixed with timer values
|
||||
- invalid `timer_reason`
|
||||
- reason-only updates when no timer is active
|
||||
- `timer_reason_text` without `timer_reason`
|
||||
- `timer_reason = "custom"` without `timer_reason_text`
|
||||
- `timer_reason_text` with any non-custom `timer_reason`
|
||||
|
||||
Countdown timestamps must be future timestamps.
|
||||
Countup timestamps may be current or past timestamps.
|
||||
|
|
@ -223,7 +281,9 @@ Payload:
|
|||
"type": "timer.updated",
|
||||
"tournament_id": 123,
|
||||
"timestamp": "2026-04-24T10:15:00Z",
|
||||
"timer_mode": "countdown"
|
||||
"timer_mode": "countdown",
|
||||
"timer_reason": "tournament_start",
|
||||
"timer_reason_text": null
|
||||
}
|
||||
```
|
||||
|
||||
|
|
@ -243,7 +303,7 @@ Frontend rule:
|
|||
Recommended flow:
|
||||
|
||||
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
|
||||
4. Replace local timer state whenever `timer.updated` arrives
|
||||
5. Derive displayed time from `Date.now()` plus stored backend timestamp
|
||||
|
|
@ -252,9 +312,11 @@ Recommended client state:
|
|||
|
||||
```ts
|
||||
type TimerState =
|
||||
| { mode: null; timestampIso: null }
|
||||
| { mode: "countdown"; timestampIso: string }
|
||||
| { mode: "countup"; timestampIso: string };
|
||||
| { mode: null; timestampIso: null; reason: null; reasonText: null }
|
||||
| { mode: "countdown"; timestampIso: string; reason: TimerReason | null; reasonText: string | null }
|
||||
| { mode: "countup"; timestampIso: string; reason: TimerReason | null; reasonText: string | null };
|
||||
|
||||
type TimerReason = "tournament_start" | "playoff_start" | "match_end" | "custom";
|
||||
```
|
||||
|
||||
Suggested derivation logic:
|
||||
|
|
@ -283,7 +345,7 @@ Recommended rendering:
|
|||
|
||||
Recommended local ticking:
|
||||
|
||||
- keep backend state as timestamp + mode
|
||||
- keep backend state as timestamp + mode + reason
|
||||
- update rendered value with local interval
|
||||
- do not poll backend every second
|
||||
|
||||
|
|
@ -300,7 +362,7 @@ Timer state is included in tournament sync snapshots.
|
|||
That means:
|
||||
|
||||
- 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 still rejects timer writes while read only
|
||||
|
||||
|
|
@ -311,6 +373,7 @@ Implemented coverage:
|
|||
- API surface timer lifecycle:
|
||||
- countdown
|
||||
- countup
|
||||
- reason-only set / change / remove
|
||||
- clear
|
||||
- validation
|
||||
- [spec/e2e/http/api_surface_spec.rb](../spec/e2e/http/api_surface_spec.rb)
|
||||
|
|
@ -321,5 +384,5 @@ Implemented coverage:
|
|||
|
||||
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
|
||||
|
|
|
|||
|
|
@ -70,6 +70,8 @@ Payload:
|
|||
"intermediate_round_participants_amount": 0,
|
||||
"timestamp": null,
|
||||
"timer_mode": null,
|
||||
"timer_reason": null,
|
||||
"timer_reason_text": null,
|
||||
"owner_username": "owner",
|
||||
"stages": [],
|
||||
"teams": [],
|
||||
|
|
|
|||
|
|
@ -0,0 +1,193 @@
|
|||
# Tournament Management API Additions
|
||||
|
||||
This document covers tournament API additions that are not specific to timers,
|
||||
team action lists, or match metadata.
|
||||
|
||||
Related docs:
|
||||
|
||||
- [timer_api.md](timer_api.md)
|
||||
- [team_action_lists_api.md](team_action_lists_api.md)
|
||||
- [match_metadata_api.md](match_metadata_api.md)
|
||||
- [account_profiles_api.md](account_profiles_api.md)
|
||||
|
||||
## Tournament Metadata Fields
|
||||
|
||||
Tournaments support optional public metadata:
|
||||
|
||||
- `location`: venue, city, stream label, or other display location
|
||||
- `starts_at`: tournament start timestamp
|
||||
- `ends_at`: tournament end timestamp
|
||||
- `website_url`: public URL for the tournament or event
|
||||
|
||||
These fields are included in:
|
||||
|
||||
- `GET /tournaments`
|
||||
- `GET /tournaments/:id?simple=true`
|
||||
- `GET /tournaments/:id`
|
||||
- `POST /tournaments`
|
||||
- `PATCH /tournaments/:id`
|
||||
- leader/follower sync snapshots
|
||||
|
||||
Full and simple tournament responses include:
|
||||
|
||||
```json
|
||||
{
|
||||
"id": 11,
|
||||
"name": "Summer Cup",
|
||||
"code": "a1b2c3",
|
||||
"public": true,
|
||||
"location": "Main Hall",
|
||||
"starts_at": "2030-06-01T08:00:00Z",
|
||||
"ends_at": "2030-06-01T16:00:00Z",
|
||||
"website_url": "https://example.com/summer-cup"
|
||||
}
|
||||
```
|
||||
|
||||
### Create With Metadata
|
||||
|
||||
Endpoint:
|
||||
|
||||
- `POST /tournaments`
|
||||
|
||||
```json
|
||||
{
|
||||
"name": "Summer Cup",
|
||||
"description": "Annual summer tournament",
|
||||
"public": true,
|
||||
"location": "Main Hall",
|
||||
"starts_at": "2030-06-01T10:00:00+02:00",
|
||||
"ends_at": "2030-06-01T18:00:00+02:00",
|
||||
"website_url": "https://example.com/summer-cup",
|
||||
"teams": [
|
||||
{
|
||||
"name": "Team Alpha"
|
||||
},
|
||||
{
|
||||
"name": "Team Beta"
|
||||
},
|
||||
{
|
||||
"name": "Team Gamma"
|
||||
},
|
||||
{
|
||||
"name": "Team Delta"
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
Authentication:
|
||||
|
||||
- authenticated user required
|
||||
- created tournament owner is the authenticated user
|
||||
|
||||
### Update Metadata
|
||||
|
||||
Endpoint:
|
||||
|
||||
- `PATCH /tournaments/:id`
|
||||
|
||||
```json
|
||||
{
|
||||
"location": "Updated Arena",
|
||||
"starts_at": "2030-07-01T10:00:00+02:00",
|
||||
"ends_at": "2030-07-01T18:00:00+02:00",
|
||||
"website_url": "https://example.com/updated"
|
||||
}
|
||||
```
|
||||
|
||||
Authentication:
|
||||
|
||||
- tournament owner required
|
||||
- read-only follower tournaments reject normal updates with `423`
|
||||
|
||||
Validation:
|
||||
|
||||
- `website_url` must be blank or an `http`/`https` URL
|
||||
- `ends_at` must be after `starts_at` when both are present
|
||||
- `location` and `website_url` are stripped and stored as `null` when blank
|
||||
- timestamp responses are serialized as ISO 8601 strings
|
||||
|
||||
Expected errors:
|
||||
|
||||
- `401` unauthenticated
|
||||
- `403` authenticated but not owner
|
||||
- `422` invalid metadata values
|
||||
- `423` follower tournament is read-only
|
||||
|
||||
## Delete Generated Group-Stage Playoffs
|
||||
|
||||
Use this endpoint when a tournament owner needs to reopen a finished group stage,
|
||||
change group-stage results, and regenerate playoffs.
|
||||
|
||||
Endpoint:
|
||||
|
||||
- `DELETE /tournaments/:id/playoffs`
|
||||
|
||||
Authentication:
|
||||
|
||||
- tournament owner required
|
||||
- read-only follower tournaments reject with `423`
|
||||
|
||||
Success response:
|
||||
|
||||
```json
|
||||
{
|
||||
"id": 11,
|
||||
"stages": [
|
||||
{
|
||||
"id": 21,
|
||||
"level": -1,
|
||||
"state": "in_progress"
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
Success behavior:
|
||||
|
||||
- deletes all playoff stages for the group-stage tournament
|
||||
- keeps the group stage
|
||||
- changes the group stage back to `in_progress`
|
||||
- enqueues tournament sync when configured
|
||||
- broadcasts the normal tournament live update
|
||||
|
||||
Deletion is allowed only when:
|
||||
|
||||
- tournament has a group stage
|
||||
- generated playoff stages exist
|
||||
- every playoff match is still effectively unplayed
|
||||
- playoff match states are `not_started`, `not_ready`, or `single_team`
|
||||
- all playoff match scores and hidden scores are zero
|
||||
|
||||
Deletion is rejected after playoff play has started or scores have changed.
|
||||
|
||||
Expected statuses:
|
||||
|
||||
- `200` playoffs deleted
|
||||
- `401` unauthenticated
|
||||
- `403` authenticated but not tournament owner
|
||||
- `404` tournament not found
|
||||
- `422` no group stage, no generated playoffs, or playoffs no longer deletable
|
||||
- `423` follower tournament is read-only
|
||||
|
||||
Common `422` errors:
|
||||
|
||||
- `Only group-stage playoffs can be deleted`
|
||||
- `Playoffs have not been generated`
|
||||
- `Playoffs cannot be deleted after matches have started or scores changed`
|
||||
|
||||
## Existing Related Endpoints
|
||||
|
||||
Match scheduling/display metadata lives in [match_metadata_api.md](match_metadata_api.md):
|
||||
|
||||
- `PATCH /matches/:id`
|
||||
- `PATCH /tournaments/:tournament_id/matches/metadata`
|
||||
|
||||
Player account profile and team ownership APIs live in
|
||||
[account_profiles_api.md](account_profiles_api.md):
|
||||
|
||||
- `GET /accounts/:id`
|
||||
- `GET /teams/:id`
|
||||
- `PATCH /teams/:id`
|
||||
- `PUT /teams/:id`
|
||||
- `POST /tournaments` team `player_email` / `player_emails` fields
|
||||
|
|
@ -0,0 +1,281 @@
|
|||
# Tournament Transaction Log API
|
||||
|
||||
## Purpose
|
||||
|
||||
Tournament transaction log is owner-readable tournament history.
|
||||
|
||||
It records ordered tournament events such as:
|
||||
|
||||
- tournament creation
|
||||
- timer changes
|
||||
- match state changes
|
||||
- match score changes
|
||||
- decider match creation
|
||||
- playoff start attempts and failures
|
||||
- playoff start success
|
||||
- group match auto-starts
|
||||
|
||||
Frontend should show this as an append-only history feed for tournament owners.
|
||||
|
||||
## Read Flow
|
||||
|
||||
Transaction log is not embedded in normal tournament payload.
|
||||
Read it from dedicated owner-only endpoint:
|
||||
|
||||
- `GET /tournaments/:id/transaction_log`
|
||||
|
||||
Auth:
|
||||
|
||||
- same Devise token auth headers as other owner APIs
|
||||
- endpoint requires authenticated tournament owner
|
||||
|
||||
Response:
|
||||
|
||||
- `200 OK`
|
||||
- JSON array
|
||||
- ordered by `sequence` ascending
|
||||
- no pagination yet
|
||||
|
||||
Example:
|
||||
|
||||
```json
|
||||
[
|
||||
{
|
||||
"id": 17,
|
||||
"sequence": 1,
|
||||
"action": "tournament.created",
|
||||
"message": "owner created tournament Spring Cup(123)",
|
||||
"metadata": {
|
||||
"tournament_id": 123,
|
||||
"tournament_name": "Spring Cup"
|
||||
},
|
||||
"created_at": "2026-04-30T10:12:42Z",
|
||||
"user": {
|
||||
"id": 4,
|
||||
"username": "owner"
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": 18,
|
||||
"sequence": 2,
|
||||
"action": "match.state_changed",
|
||||
"message": "owner changed match 77 (Team A(10) vs Team B(11)) to running",
|
||||
"metadata": {
|
||||
"match_id": 77,
|
||||
"stage_type": "playoffs",
|
||||
"old_state": "not_started",
|
||||
"new_state": "in_progress",
|
||||
"team_ids": [10, 11],
|
||||
"teams": [
|
||||
{ "id": 10, "name": "Team A" },
|
||||
{ "id": 11, "name": "Team B" }
|
||||
],
|
||||
"winner_team_id": null,
|
||||
"advanced_match_ids": [],
|
||||
"group_scores": []
|
||||
},
|
||||
"created_at": "2026-04-30T10:13:01Z",
|
||||
"user": {
|
||||
"id": 4,
|
||||
"username": "owner"
|
||||
}
|
||||
}
|
||||
]
|
||||
```
|
||||
|
||||
## Field Contract
|
||||
|
||||
Each entry has:
|
||||
|
||||
- `id`: DB id for entry
|
||||
- `sequence`: per-tournament monotonic order number, starts at `1`
|
||||
- `action`: machine-readable action key
|
||||
- `message`: human-readable owner history text
|
||||
- `metadata`: structured action-specific data
|
||||
- `created_at`: ISO-8601 timestamp
|
||||
- `user`: actor object, or `null` for system actions
|
||||
|
||||
`user` shape:
|
||||
|
||||
```json
|
||||
{
|
||||
"id": 4,
|
||||
"username": "owner"
|
||||
}
|
||||
```
|
||||
|
||||
Frontend display guidance:
|
||||
|
||||
- sort by `sequence`, not by timestamp
|
||||
- use `message` for first UI pass
|
||||
- use `metadata` for links/details/chips
|
||||
- tolerate unknown future `action` values
|
||||
- tolerate extra `metadata` fields
|
||||
|
||||
## Current Action Keys
|
||||
|
||||
Current `action` values:
|
||||
|
||||
- `tournament.created`
|
||||
- `tournament.timer_changed`
|
||||
- `match.state_changed`
|
||||
- `match_score.updated`
|
||||
- `decider_match.created`
|
||||
- `playoffs.start_failed`
|
||||
- `playoffs.started`
|
||||
- `group_matches.started`
|
||||
|
||||
Future backend work may add more action keys.
|
||||
Unknown actions should still render via `message`.
|
||||
|
||||
## Metadata Notes
|
||||
|
||||
`metadata` is stable JSON, but shape depends on `action`.
|
||||
|
||||
Common match metadata includes:
|
||||
|
||||
- `match_id`
|
||||
- `stage_id`
|
||||
- `group_id`
|
||||
- `stage_type`: `groupstage` or `playoffs`
|
||||
- `team_ids`
|
||||
- `teams`
|
||||
- `winner_team_id`
|
||||
- `advanced_match_ids`
|
||||
- `group_scores`
|
||||
|
||||
Team object shape:
|
||||
|
||||
```json
|
||||
{
|
||||
"id": 10,
|
||||
"name": "Team A"
|
||||
}
|
||||
```
|
||||
|
||||
Group score object shape:
|
||||
|
||||
```json
|
||||
{
|
||||
"team_id": 10,
|
||||
"team_name": "Team A",
|
||||
"points": 9,
|
||||
"matches_played": 3,
|
||||
"wins": 3,
|
||||
"draws": 0,
|
||||
"losses": 0,
|
||||
"score": 12,
|
||||
"score_against": 4,
|
||||
"score_difference": 8,
|
||||
"position": 1
|
||||
}
|
||||
```
|
||||
|
||||
## Example Actions
|
||||
|
||||
### Match state change
|
||||
|
||||
```json
|
||||
{
|
||||
"action": "match.state_changed",
|
||||
"message": "owner changed match 77 to finished (Team A(10) wins) - playoffs - Team A(10) advances to match 80",
|
||||
"metadata": {
|
||||
"match_id": 77,
|
||||
"stage_type": "playoffs",
|
||||
"old_state": "in_progress",
|
||||
"new_state": "finished",
|
||||
"winner_team_id": 10,
|
||||
"advanced_match_ids": [80]
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Match score update
|
||||
|
||||
```json
|
||||
{
|
||||
"action": "match_score.updated",
|
||||
"message": "owner changed matchscore 91 from match 77 to 3 (3 points for Team A(10) in match vs Team B(11))",
|
||||
"metadata": {
|
||||
"match_id": 77,
|
||||
"match_score_id": 91,
|
||||
"team_id": 10,
|
||||
"changes": {
|
||||
"points": {
|
||||
"from": 2,
|
||||
"to": 3
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Playoff start failure
|
||||
|
||||
```json
|
||||
{
|
||||
"action": "playoffs.start_failed",
|
||||
"message": "owner attempted playoff start, failed because decider missing",
|
||||
"metadata": {
|
||||
"stage_id": 12,
|
||||
"reason": "decider_missing",
|
||||
"blocking_ties": [
|
||||
{
|
||||
"group_id": 5,
|
||||
"team_ids": [10, 11],
|
||||
"teams": [
|
||||
{ "id": 10, "name": "Team A" },
|
||||
{ "id": 11, "name": "Team B" }
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Playoffs started
|
||||
|
||||
```json
|
||||
{
|
||||
"action": "playoffs.started",
|
||||
"message": "owner started playoffs, advancing teams: Team A(10), Team B(11), Team C(12), Team D(13)",
|
||||
"metadata": {
|
||||
"stage_id": 12,
|
||||
"advancing_team_ids": [10, 11, 12, 13],
|
||||
"advancing_teams": [
|
||||
{ "id": 10, "name": "Team A" },
|
||||
{ "id": 11, "name": "Team B" },
|
||||
{ "id": 12, "name": "Team C" },
|
||||
{ "id": 13, "name": "Team D" }
|
||||
]
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Error Behavior
|
||||
|
||||
Expected errors:
|
||||
|
||||
- `401 Unauthorized`: missing or invalid auth
|
||||
- `403 Forbidden`: authenticated user is not tournament owner
|
||||
- `404 Not Found`: tournament id does not exist
|
||||
|
||||
Read-only follower tournaments:
|
||||
|
||||
- log read is allowed for owner
|
||||
- log write is internal only; frontend has no write endpoint
|
||||
|
||||
## Frontend Implementation Guide
|
||||
|
||||
Recommended owner-history flow:
|
||||
|
||||
1. Load tournament as usual.
|
||||
2. If current user is owner, request `GET /tournaments/:id/transaction_log`.
|
||||
3. Render entries sorted by `sequence`.
|
||||
4. Use `message` as main text.
|
||||
5. Show `created_at` as event time.
|
||||
6. Link matches/teams from `metadata` when ids are present.
|
||||
7. Refresh after owner mutations, or poll while history panel is open.
|
||||
|
||||
There is no websocket stream for transaction log yet.
|
||||
Existing tournament/match websocket updates still fire for live tournament state.
|
||||
|
|
@ -37,12 +37,19 @@ services:
|
|||
source-postgres:
|
||||
condition: service_healthy
|
||||
environment:
|
||||
RAILS_ENV: ${TURNIERE_BLACKBOX_RAILS_ENV:-production}
|
||||
WEB_CONCURRENCY: ${TURNIERE_BLACKBOX_WEB_CONCURRENCY:-1}
|
||||
RAILS_MAX_THREADS: ${TURNIERE_BLACKBOX_RAILS_MAX_THREADS:-5}
|
||||
RAILS_DB_POOL: ${TURNIERE_BLACKBOX_RAILS_DB_POOL:-5}
|
||||
SIDEKIQ_CONCURRENCY: ${TURNIERE_BLACKBOX_SIDEKIQ_CONCURRENCY:-5}
|
||||
DATABASE_URL: postgres://${TURNIERE_BLACKBOX_POSTGRES_USER:-turniere}:${TURNIERE_BLACKBOX_POSTGRES_PASSWORD:-turniere}@source-postgres:5432/${TURNIERE_BLACKBOX_SOURCE_POSTGRES_DB:-turniere_source_blackbox}
|
||||
POSTGRES_URL: postgres://${TURNIERE_BLACKBOX_POSTGRES_USER:-turniere}:${TURNIERE_BLACKBOX_POSTGRES_PASSWORD:-turniere}@source-postgres:5432/${TURNIERE_BLACKBOX_SOURCE_POSTGRES_DB:-turniere_source_blackbox}
|
||||
POSTGRES_USERNAME: ${TURNIERE_BLACKBOX_POSTGRES_USER:-turniere}
|
||||
POSTGRES_PASSWORD: ${TURNIERE_BLACKBOX_POSTGRES_PASSWORD:-turniere}
|
||||
SECRET_KEY_BASE: ${TURNIERE_BLACKBOX_SECRET_KEY_BASE:-turniere-blackbox-secret-key-base-please-change-in-real-prod}
|
||||
MAILGUN_API_KEY: ${TURNIERE_BLACKBOX_MAILGUN_API_KEY:-blackbox-test-api-key}
|
||||
MAILGUN_DOMAIN: ${TURNIERE_BLACKBOX_MAILGUN_DOMAIN:-blackbox.example.com}
|
||||
TURNIERE_DISABLE_EMAIL_DELIVERY: ${TURNIERE_BLACKBOX_DISABLE_EMAIL_DELIVERY:-1}
|
||||
TOURNAMENT_SYNC_HTTP_TIMEOUT_SECONDS: ${TURNIERE_BLACKBOX_TOURNAMENT_SYNC_HTTP_TIMEOUT_SECONDS:-3}
|
||||
RAILS_LOG_TO_STDOUT: "1"
|
||||
RAILS_SERVE_STATIC_FILES: "1"
|
||||
|
|
@ -58,12 +65,19 @@ services:
|
|||
follower-postgres:
|
||||
condition: service_healthy
|
||||
environment:
|
||||
RAILS_ENV: ${TURNIERE_BLACKBOX_RAILS_ENV:-production}
|
||||
WEB_CONCURRENCY: ${TURNIERE_BLACKBOX_WEB_CONCURRENCY:-1}
|
||||
RAILS_MAX_THREADS: ${TURNIERE_BLACKBOX_RAILS_MAX_THREADS:-5}
|
||||
RAILS_DB_POOL: ${TURNIERE_BLACKBOX_RAILS_DB_POOL:-5}
|
||||
SIDEKIQ_CONCURRENCY: ${TURNIERE_BLACKBOX_SIDEKIQ_CONCURRENCY:-5}
|
||||
DATABASE_URL: postgres://${TURNIERE_BLACKBOX_POSTGRES_USER:-turniere}:${TURNIERE_BLACKBOX_POSTGRES_PASSWORD:-turniere}@follower-postgres:5432/${TURNIERE_BLACKBOX_FOLLOWER_POSTGRES_DB:-turniere_follower_blackbox}
|
||||
POSTGRES_URL: postgres://${TURNIERE_BLACKBOX_POSTGRES_USER:-turniere}:${TURNIERE_BLACKBOX_POSTGRES_PASSWORD:-turniere}@follower-postgres:5432/${TURNIERE_BLACKBOX_FOLLOWER_POSTGRES_DB:-turniere_follower_blackbox}
|
||||
POSTGRES_USERNAME: ${TURNIERE_BLACKBOX_POSTGRES_USER:-turniere}
|
||||
POSTGRES_PASSWORD: ${TURNIERE_BLACKBOX_POSTGRES_PASSWORD:-turniere}
|
||||
SECRET_KEY_BASE: ${TURNIERE_BLACKBOX_SECRET_KEY_BASE:-turniere-blackbox-secret-key-base-please-change-in-real-prod}
|
||||
MAILGUN_API_KEY: ${TURNIERE_BLACKBOX_MAILGUN_API_KEY:-blackbox-test-api-key}
|
||||
MAILGUN_DOMAIN: ${TURNIERE_BLACKBOX_MAILGUN_DOMAIN:-blackbox.example.com}
|
||||
TURNIERE_DISABLE_EMAIL_DELIVERY: ${TURNIERE_BLACKBOX_DISABLE_EMAIL_DELIVERY:-1}
|
||||
TOURNAMENT_SYNC_HTTP_TIMEOUT_SECONDS: ${TURNIERE_BLACKBOX_TOURNAMENT_SYNC_HTTP_TIMEOUT_SECONDS:-3}
|
||||
RAILS_LOG_TO_STDOUT: "1"
|
||||
RAILS_SERVE_STATIC_FILES: "1"
|
||||
|
|
|
|||
|
|
@ -19,12 +19,19 @@ services:
|
|||
postgres:
|
||||
condition: service_healthy
|
||||
environment:
|
||||
RAILS_ENV: ${TURNIERE_BLACKBOX_RAILS_ENV:-production}
|
||||
WEB_CONCURRENCY: ${TURNIERE_BLACKBOX_WEB_CONCURRENCY:-1}
|
||||
RAILS_MAX_THREADS: ${TURNIERE_BLACKBOX_RAILS_MAX_THREADS:-5}
|
||||
RAILS_DB_POOL: ${TURNIERE_BLACKBOX_RAILS_DB_POOL:-5}
|
||||
SIDEKIQ_CONCURRENCY: ${TURNIERE_BLACKBOX_SIDEKIQ_CONCURRENCY:-5}
|
||||
DATABASE_URL: postgres://${TURNIERE_BLACKBOX_POSTGRES_USER:-turniere}:${TURNIERE_BLACKBOX_POSTGRES_PASSWORD:-turniere}@postgres:5432/${TURNIERE_BLACKBOX_POSTGRES_DB:-turniere_blackbox}
|
||||
POSTGRES_URL: postgres://${TURNIERE_BLACKBOX_POSTGRES_USER:-turniere}:${TURNIERE_BLACKBOX_POSTGRES_PASSWORD:-turniere}@postgres:5432/${TURNIERE_BLACKBOX_POSTGRES_DB:-turniere_blackbox}
|
||||
POSTGRES_USERNAME: ${TURNIERE_BLACKBOX_POSTGRES_USER:-turniere}
|
||||
POSTGRES_PASSWORD: ${TURNIERE_BLACKBOX_POSTGRES_PASSWORD:-turniere}
|
||||
SECRET_KEY_BASE: ${TURNIERE_BLACKBOX_SECRET_KEY_BASE:-turniere-blackbox-secret-key-base-please-change-in-real-prod}
|
||||
MAILGUN_API_KEY: ${TURNIERE_BLACKBOX_MAILGUN_API_KEY:-blackbox-test-api-key}
|
||||
MAILGUN_DOMAIN: ${TURNIERE_BLACKBOX_MAILGUN_DOMAIN:-blackbox.example.com}
|
||||
TURNIERE_DISABLE_EMAIL_DELIVERY: ${TURNIERE_BLACKBOX_DISABLE_EMAIL_DELIVERY:-1}
|
||||
RAILS_LOG_TO_STDOUT: "1"
|
||||
RAILS_SERVE_STATIC_FILES: "1"
|
||||
ports:
|
||||
|
|
|
|||
|
|
@ -80,7 +80,7 @@ module TurniereE2E
|
|||
login!(**@login_credentials) if refresh_session && should_refresh_session?(method, path)
|
||||
|
||||
uri = URI.parse("#{base_url}#{path}")
|
||||
uri.query = URI.encode_www_form(params) if params.present?
|
||||
uri.query = URI.encode_www_form(params) if params&.any?
|
||||
|
||||
request_class = case method
|
||||
when :get then Net::HTTP::Get
|
||||
|
|
|
|||
|
|
@ -0,0 +1,338 @@
|
|||
# frozen_string_literal: true
|
||||
|
||||
require 'json'
|
||||
require 'time'
|
||||
|
||||
require_relative 'action_cable_client'
|
||||
require_relative 'api_client'
|
||||
require_relative 'scenario_runner'
|
||||
|
||||
module TurniereE2E
|
||||
class LoadTestRunner
|
||||
DEFAULT_HTTP_CLIENTS = [1, 10, 50, 100].freeze
|
||||
DEFAULT_WEBSOCKET_CLIENTS = [1, 10, 50, 100, 200].freeze
|
||||
|
||||
def initialize(**options)
|
||||
@base_url = options.fetch(:base_url).delete_suffix('/')
|
||||
@email = options[:email]
|
||||
@password = options[:password]
|
||||
@username = options[:username]
|
||||
@tournament_id = options[:tournament_id]
|
||||
@http_clients = options.fetch(:http_clients, DEFAULT_HTTP_CLIENTS)
|
||||
@websocket_clients = options.fetch(:websocket_clients, DEFAULT_WEBSOCKET_CLIENTS)
|
||||
@http_requests_per_client = options.fetch(:http_requests_per_client, 3).to_i
|
||||
@websocket_hold_seconds = options.fetch(:websocket_hold_seconds, 1.0).to_f
|
||||
@websocket_connect_batch_size = options.fetch(:websocket_connect_batch_size, 25).to_i
|
||||
@group_count = options.fetch(:group_count, 4).to_i
|
||||
@teams_per_group = options.fetch(:teams_per_group, 4).to_i
|
||||
@playoff_teams_amount = options.fetch(:playoff_teams_amount, 8).to_i
|
||||
@fail_on_error = options.fetch(:fail_on_error, true)
|
||||
end
|
||||
|
||||
def run
|
||||
tournament = prepare_tournament
|
||||
result = {
|
||||
base_url: base_url,
|
||||
tournament: tournament,
|
||||
http: http_clients.map { |client_count| run_http_level(tournament, client_count) },
|
||||
websocket: websocket_clients.map { |client_count| run_websocket_level(tournament, client_count) },
|
||||
metrics: metrics_summary
|
||||
}
|
||||
|
||||
assert_success!(result) if fail_on_error
|
||||
result
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
attr_reader :base_url, :email, :password, :username, :tournament_id, :http_clients, :websocket_clients,
|
||||
:http_requests_per_client, :websocket_hold_seconds, :websocket_connect_batch_size, :group_count,
|
||||
:teams_per_group, :playoff_teams_amount, :fail_on_error
|
||||
|
||||
def prepare_tournament
|
||||
return fetch_existing_tournament if tournament_id.to_s.strip != ''
|
||||
|
||||
runner = ScenarioRunner.new(base_url: base_url, email: email, password: password, username: username)
|
||||
runner.wait_for_healthcheck!
|
||||
result = runner.run_group_stage_render_profile(
|
||||
group_count: group_count,
|
||||
teams_per_group: teams_per_group,
|
||||
playoff_teams_amount: playoff_teams_amount,
|
||||
stop_at: :created
|
||||
)
|
||||
result.fetch(:tournament)
|
||||
end
|
||||
|
||||
def fetch_existing_tournament
|
||||
client = ApiClient.new(base_url: base_url)
|
||||
response = client.get("/tournaments/#{tournament_id}")
|
||||
unless response.fetch(:status) == 200
|
||||
raise ApiError.new(
|
||||
"load-test tournament fetch failed with #{response.fetch(:status)}",
|
||||
status: response.fetch(:status),
|
||||
body: response.fetch(:json)
|
||||
)
|
||||
end
|
||||
|
||||
summarize_tournament(response.fetch(:json))
|
||||
end
|
||||
|
||||
def run_http_level(tournament, client_count)
|
||||
path = "/tournaments/#{tournament.fetch(:id)}"
|
||||
records = run_threads(client_count) do
|
||||
client = ApiClient.new(base_url: base_url)
|
||||
Array.new(http_requests_per_client) { measure_http_request(client, path) }
|
||||
end.flatten
|
||||
|
||||
http_summary(client_count, records)
|
||||
end
|
||||
|
||||
def run_websocket_level(tournament, client_count)
|
||||
clients = []
|
||||
baseline_connections = metric_scalar('turniere_action_cable_connections').to_i
|
||||
records = run_websocket_open_batches(tournament.fetch(:id), client_count, clients)
|
||||
successful = records.count { |record| record[:ok] }
|
||||
observed_connections = wait_for_connection_metric(successful)
|
||||
sleep websocket_hold_seconds if websocket_hold_seconds.positive?
|
||||
|
||||
websocket_summary(client_count, records, baseline_connections, successful, observed_connections)
|
||||
ensure
|
||||
clients.each(&:close)
|
||||
end
|
||||
|
||||
def run_websocket_open_batches(tournament_id, client_count, clients)
|
||||
batch_size = websocket_connect_batch_size.positive? ? websocket_connect_batch_size : client_count
|
||||
|
||||
Array.new(client_count) { true }.each_slice(batch_size).flat_map do |batch|
|
||||
run_threads(batch.count) do
|
||||
measure_websocket_open(tournament_id).tap do |record|
|
||||
clients << record.delete(:client) if record[:client]
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
def run_threads(count)
|
||||
return [] if count.to_i <= 0
|
||||
|
||||
ready = Queue.new
|
||||
start = Queue.new
|
||||
results = Queue.new
|
||||
threads = Array.new(count) do
|
||||
Thread.new do
|
||||
ready << true
|
||||
start.pop
|
||||
results << yield
|
||||
rescue StandardError => e
|
||||
results << [{ error: "#{e.class}: #{e.message}" }]
|
||||
end
|
||||
end
|
||||
|
||||
count.times { ready.pop }
|
||||
started_at = monotonic_time
|
||||
count.times { start << true }
|
||||
threads.each(&:join)
|
||||
finished_at = monotonic_time
|
||||
|
||||
drain_queue(results).flatten.map do |record|
|
||||
record.is_a?(Hash) ? record.merge(level_duration_seconds: finished_at - started_at) : record
|
||||
end
|
||||
end
|
||||
|
||||
def measure_http_request(client, path)
|
||||
started_at = monotonic_time
|
||||
response = client.get(path)
|
||||
{
|
||||
status: response.fetch(:status),
|
||||
duration_ms: elapsed_ms(started_at),
|
||||
body: error_body(response)
|
||||
}.compact
|
||||
rescue StandardError => e
|
||||
{ error: "#{e.class}: #{e.message}", duration_ms: elapsed_ms(started_at) }
|
||||
end
|
||||
|
||||
def measure_websocket_open(tournament_id)
|
||||
client = ActionCableClient.new(base_url: base_url)
|
||||
started_at = monotonic_time
|
||||
client.subscribe_tournament!(tournament_id: tournament_id)
|
||||
payload = client.wait_for_tournament_payload!(timeout: 20)
|
||||
{
|
||||
ok: true,
|
||||
duration_ms: elapsed_ms(started_at),
|
||||
payload_type: payload.fetch('type'),
|
||||
client: client
|
||||
}
|
||||
rescue StandardError => e
|
||||
client&.close
|
||||
{ ok: false, duration_ms: elapsed_ms(started_at), error: "#{e.class}: #{e.message}" }
|
||||
end
|
||||
|
||||
def wait_for_connection_metric(expected, timeout: 10)
|
||||
deadline = monotonic_time + timeout
|
||||
loop do
|
||||
value = metric_scalar('turniere_action_cable_connections')
|
||||
return value if value && value >= expected
|
||||
return value if monotonic_time >= deadline
|
||||
|
||||
sleep 0.2
|
||||
end
|
||||
end
|
||||
|
||||
def metrics_summary
|
||||
{
|
||||
action_cable_connections: metric_scalar('turniere_action_cable_connections'),
|
||||
action_cable_connections_total: metric_scalar('turniere_action_cable_connections_total'),
|
||||
ruby_threads: metric_scalar('turniere_ruby_threads'),
|
||||
active_record_connections: metric_samples('turniere_active_record_connection_pool')
|
||||
}
|
||||
end
|
||||
|
||||
def metric_scalar(name)
|
||||
metric_samples(name).map { |sample| sample.fetch(:value) }.max
|
||||
end
|
||||
|
||||
def metric_samples(name)
|
||||
body = metrics_body
|
||||
body.lines.filter_map do |line|
|
||||
next if line.start_with?('#')
|
||||
|
||||
sample, value = line.split(/\s+/, 2)
|
||||
next unless sample == name || sample.start_with?("#{name}{")
|
||||
|
||||
{ sample: sample, value: value.to_f }
|
||||
end
|
||||
end
|
||||
|
||||
def metrics_body
|
||||
response = ApiClient.new(base_url: base_url).get('/metrics')
|
||||
return '' unless response.fetch(:status) == 200
|
||||
|
||||
response.dig(:json, :raw_body).to_s
|
||||
end
|
||||
|
||||
def assert_success!(result)
|
||||
failures = []
|
||||
result.fetch(:http).each { |level| failures.concat(http_failures(level)) }
|
||||
result.fetch(:websocket).each { |level| failures.concat(websocket_failures(level)) }
|
||||
|
||||
raise "load test failed: #{failures.join('; ')}" unless failures.empty?
|
||||
end
|
||||
|
||||
def http_summary(client_count, records)
|
||||
durations = records.filter_map { |record| record[:duration_ms] }
|
||||
errors = failed_http_records(records)
|
||||
|
||||
{
|
||||
clients: client_count,
|
||||
requests: records.count,
|
||||
ok: records.count - errors.count,
|
||||
errors: errors.count,
|
||||
statuses: tally(records.filter_map { |record| record[:status] }),
|
||||
duration_ms: durations.sum.round(1),
|
||||
p50_ms: percentile(durations, 0.50),
|
||||
p95_ms: percentile(durations, 0.95),
|
||||
max_ms: durations.max&.round(1),
|
||||
requests_per_second: requests_per_second(records.count, records),
|
||||
error_samples: errors.first(3).map { |record| record.slice(:status, :error, :body) }
|
||||
}
|
||||
end
|
||||
|
||||
def websocket_summary(client_count, records, baseline_connections, successful, observed_connections)
|
||||
durations = records.filter_map { |record| record[:duration_ms] }
|
||||
errors = records.reject { |record| record[:ok] }
|
||||
|
||||
{
|
||||
clients: client_count,
|
||||
opened: successful,
|
||||
errors: errors.count,
|
||||
connection_metric: observed_connections,
|
||||
expected_connection_metric: successful,
|
||||
baseline_connection_metric: baseline_connections,
|
||||
p50_open_ms: percentile(durations, 0.50),
|
||||
p95_open_ms: percentile(durations, 0.95),
|
||||
max_open_ms: durations.max&.round(1),
|
||||
error_samples: errors.first(3).map { |record| record.slice(:error) }
|
||||
}
|
||||
end
|
||||
|
||||
def failed_http_records(records)
|
||||
records.select { |record| record[:error] || record[:status].to_i >= 400 }
|
||||
end
|
||||
|
||||
def http_failures(level)
|
||||
return [] unless level.fetch(:errors).positive?
|
||||
|
||||
["HTTP #{level.fetch(:clients)} clients: #{level.fetch(:errors)} failed"]
|
||||
end
|
||||
|
||||
def websocket_failures(level)
|
||||
failures = []
|
||||
failures << "WS #{level.fetch(:clients)} clients: #{level.fetch(:errors)} failed" if level.fetch(:errors).positive?
|
||||
failures << "WS #{level.fetch(:clients)} clients: only #{level.fetch(:opened)} opened" if websocket_open_short?(level)
|
||||
if websocket_metric_short?(level)
|
||||
failures << "WS #{level.fetch(:clients)} clients: metrics saw #{level.fetch(:connection_metric)} connections"
|
||||
end
|
||||
failures
|
||||
end
|
||||
|
||||
def websocket_open_short?(level)
|
||||
level.fetch(:opened) < level.fetch(:clients)
|
||||
end
|
||||
|
||||
def websocket_metric_short?(level)
|
||||
level.fetch(:opened).positive? &&
|
||||
level.fetch(:connection_metric).to_i < level.fetch(:expected_connection_metric).to_i
|
||||
end
|
||||
|
||||
def summarize_tournament(tournament)
|
||||
{
|
||||
id: tournament.fetch(:id),
|
||||
code: tournament.fetch(:code),
|
||||
playoff_teams_amount: tournament.fetch(:playoff_teams_amount),
|
||||
stage_count: tournament.fetch(:stages).count,
|
||||
team_count: tournament.fetch(:teams).count
|
||||
}
|
||||
end
|
||||
|
||||
def error_body(response)
|
||||
return nil if response.fetch(:status).to_i < 400
|
||||
|
||||
response.fetch(:json)
|
||||
end
|
||||
|
||||
def requests_per_second(request_count, records)
|
||||
level_duration_seconds = records.filter_map { |record| record[:level_duration_seconds] }.max.to_f
|
||||
return nil unless level_duration_seconds.positive?
|
||||
|
||||
(request_count / level_duration_seconds).round(2)
|
||||
end
|
||||
|
||||
def percentile(values, quantile)
|
||||
sorted = values.compact.sort
|
||||
return nil if sorted.empty?
|
||||
|
||||
index = [(sorted.length * quantile).ceil - 1, 0].max
|
||||
sorted.fetch(index).round(1)
|
||||
end
|
||||
|
||||
def tally(values)
|
||||
values.each_with_object(Hash.new(0)) { |value, counts| counts[value.to_s] += 1 }
|
||||
end
|
||||
|
||||
def drain_queue(queue)
|
||||
items = []
|
||||
items << queue.pop(true) until queue.empty?
|
||||
items
|
||||
rescue ThreadError
|
||||
items
|
||||
end
|
||||
|
||||
def monotonic_time
|
||||
Process.clock_gettime(Process::CLOCK_MONOTONIC)
|
||||
end
|
||||
|
||||
def elapsed_ms(started_at)
|
||||
((monotonic_time - started_at) * 1000.0).round(1)
|
||||
end
|
||||
end
|
||||
end
|
||||
|
|
@ -12,7 +12,7 @@ module TurniereE2E
|
|||
attr_reader :client
|
||||
|
||||
def initialize(base_url:, email: nil, password: nil, username: nil)
|
||||
@client = ApiClient.new(base_url:)
|
||||
@client = ApiClient.new(base_url: base_url)
|
||||
@email = email
|
||||
@password = password
|
||||
@username = username
|
||||
|
|
@ -30,31 +30,25 @@ module TurniereE2E
|
|||
|
||||
timer_response = patch!(
|
||||
"/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) }
|
||||
if stop_at == :timer_set
|
||||
return result(session:, tournament:, checkpoints:,
|
||||
stop_at:)
|
||||
end
|
||||
checkpoints[:timer_set] = {
|
||||
timestamp: timer_response.fetch(:timestamp),
|
||||
timer_reason: timer_response.fetch(:timer_reason)
|
||||
}
|
||||
return checkpoint_result(session, tournament, checkpoints, stop_at) if stop_at == :timer_set
|
||||
|
||||
upcoming_matches = get!(
|
||||
"/tournaments/#{tournament.fetch(:id)}/matches",
|
||||
params: { state: 'upcoming' }
|
||||
)
|
||||
checkpoints[:upcoming_group_matches] = { match_ids: upcoming_matches.map { |match| match.fetch(:id) } }
|
||||
if stop_at == :upcoming_group_matches
|
||||
return result(session:, tournament:, checkpoints:,
|
||||
stop_at:)
|
||||
end
|
||||
return checkpoint_result(session, tournament, checkpoints, stop_at) if stop_at == :upcoming_group_matches
|
||||
|
||||
finish_group_stage_matches!(tournament)
|
||||
tournament = fetch_tournament!(tournament.fetch(:id))
|
||||
checkpoints[:groups_finished] = summarize_tournament(tournament)
|
||||
if stop_at == :groups_finished
|
||||
return result(session:, tournament:, checkpoints:,
|
||||
stop_at:)
|
||||
end
|
||||
return checkpoint_result(session, tournament, checkpoints, stop_at) if stop_at == :groups_finished
|
||||
|
||||
statistics = get!("/tournaments/#{tournament.fetch(:id)}/statistics")
|
||||
checkpoints[:statistics] = {
|
||||
|
|
@ -66,27 +60,21 @@ module TurniereE2E
|
|||
finish_stage!(group_stage_of!(tournament).fetch(:id))
|
||||
tournament = fetch_tournament!(tournament.fetch(:id))
|
||||
checkpoints[:playoffs_created] = summarize_tournament(tournament)
|
||||
if stop_at == :playoffs_created
|
||||
return result(session:, tournament:, checkpoints:,
|
||||
stop_at:)
|
||||
end
|
||||
return checkpoint_result(session, tournament, checkpoints, stop_at) if stop_at == :playoffs_created
|
||||
|
||||
run_playoffs_until_before_final!(tournament.fetch(:id))
|
||||
tournament = fetch_tournament!(tournament.fetch(:id))
|
||||
checkpoints[:before_final_finish_attempt] = summarize_tournament(tournament)
|
||||
if stop_at == :before_final_finish_attempt
|
||||
return result(session:, tournament:, checkpoints:,
|
||||
stop_at:)
|
||||
end
|
||||
return checkpoint_result(session, tournament, checkpoints, stop_at) if stop_at == :before_final_finish_attempt
|
||||
|
||||
finish_attempt = finish_final_stage_matches!(tournament)
|
||||
tournament = fetch_tournament!(tournament.fetch(:id))
|
||||
checkpoints[:final_finish_attempted] = {
|
||||
finish_attempt:,
|
||||
finish_attempt: finish_attempt,
|
||||
tournament: summarize_tournament(tournament)
|
||||
}
|
||||
|
||||
result(session:, tournament:, checkpoints:, stop_at:)
|
||||
result(session: session, tournament: tournament, checkpoints: checkpoints, stop_at: stop_at)
|
||||
end
|
||||
|
||||
def run_group_stage_intermediate_round(stop_at: :final_finish_attempted)
|
||||
|
|
@ -99,8 +87,8 @@ module TurniereE2E
|
|||
created: summarize_tournament(tournament)
|
||||
}
|
||||
if stop_at == :created
|
||||
return result(session:, tournament:, checkpoints:,
|
||||
stop_at:)
|
||||
return result(session: session, tournament: tournament, checkpoints: checkpoints,
|
||||
stop_at: stop_at)
|
||||
end
|
||||
|
||||
finish_group_stage_matches!(tournament)
|
||||
|
|
@ -108,44 +96,87 @@ module TurniereE2E
|
|||
tournament = fetch_tournament!(tournament.fetch(:id))
|
||||
checkpoints[:playoffs_created] = summarize_tournament(tournament)
|
||||
if stop_at == :playoffs_created
|
||||
return result(session:, tournament:, checkpoints:,
|
||||
stop_at:)
|
||||
return result(session: session, tournament: tournament, checkpoints: checkpoints,
|
||||
stop_at: stop_at)
|
||||
end
|
||||
|
||||
run_playoffs_until_before_final!(tournament.fetch(:id))
|
||||
tournament = fetch_tournament!(tournament.fetch(:id))
|
||||
checkpoints[:before_final_finish_attempt] = summarize_tournament(tournament)
|
||||
if stop_at == :before_final_finish_attempt
|
||||
return result(session:, tournament:, checkpoints:,
|
||||
stop_at:)
|
||||
return result(session: session, tournament: tournament, checkpoints: checkpoints,
|
||||
stop_at: stop_at)
|
||||
end
|
||||
|
||||
finish_attempt = finish_final_stage_matches!(tournament)
|
||||
tournament = fetch_tournament!(tournament.fetch(:id))
|
||||
checkpoints[:final_finish_attempted] = {
|
||||
finish_attempt:,
|
||||
finish_attempt: finish_attempt,
|
||||
tournament: summarize_tournament(tournament)
|
||||
}
|
||||
|
||||
result(session:, tournament:, checkpoints:, stop_at:)
|
||||
result(session: session, tournament: tournament, checkpoints: checkpoints, stop_at: stop_at)
|
||||
end
|
||||
|
||||
def run_group_stage_playoff_recreation(stop_at: :third_playoffs_created)
|
||||
session = register_session!
|
||||
tournament = create_tournament!(
|
||||
create_group_stage_payload(group_count: 2, teams_per_group: 2, playoff_teams_amount: 2)
|
||||
)
|
||||
|
||||
checkpoints = {
|
||||
created: summarize_tournament(tournament)
|
||||
}
|
||||
rounds = playoff_recreation_rounds
|
||||
created_keys = %i[first_playoffs_created second_playoffs_created third_playoffs_created]
|
||||
deleted_keys = %i[first_playoffs_deleted second_playoffs_deleted]
|
||||
|
||||
rounds.each_with_index do |round, index|
|
||||
tournament = fetch_tournament!(tournament.fetch(:id))
|
||||
apply_group_score_round!(tournament, round.fetch(:scores_by_team_name))
|
||||
finish_stage!(group_stage_of!(fetch_tournament!(tournament.fetch(:id))).fetch(:id))
|
||||
tournament = fetch_tournament!(tournament.fetch(:id))
|
||||
|
||||
created_key = created_keys.fetch(index)
|
||||
checkpoints[created_key] = summarize_tournament(tournament).merge(
|
||||
playoff_matchups: playoff_matchups(tournament)
|
||||
)
|
||||
if stop_at == created_key
|
||||
return result(session: session, tournament: tournament, checkpoints: checkpoints,
|
||||
stop_at: stop_at)
|
||||
end
|
||||
|
||||
next if index == rounds.size - 1
|
||||
|
||||
delete_playoffs!(tournament.fetch(:id))
|
||||
tournament = fetch_tournament!(tournament.fetch(:id))
|
||||
deleted_key = deleted_keys.fetch(index)
|
||||
checkpoints[deleted_key] = summarize_tournament(tournament)
|
||||
if stop_at == deleted_key
|
||||
return result(session: session, tournament: tournament, checkpoints: checkpoints,
|
||||
stop_at: stop_at)
|
||||
end
|
||||
end
|
||||
|
||||
result(session: session, tournament: tournament, checkpoints: checkpoints, stop_at: stop_at)
|
||||
end
|
||||
|
||||
def run_playoff_only(team_count:, stop_at: :completed)
|
||||
session = register_session!
|
||||
tournament = create_tournament!(create_playoff_only_payload(team_count:))
|
||||
tournament = create_tournament!(create_playoff_only_payload(team_count: team_count))
|
||||
checkpoints = {
|
||||
created: summarize_tournament(tournament)
|
||||
}
|
||||
if stop_at == :created
|
||||
return result(session:, tournament:, checkpoints:,
|
||||
stop_at:)
|
||||
return result(session: session, tournament: tournament, checkpoints: checkpoints,
|
||||
stop_at: stop_at)
|
||||
end
|
||||
|
||||
run_playoffs_until_complete!(tournament.fetch(:id))
|
||||
tournament = fetch_tournament!(tournament.fetch(:id))
|
||||
checkpoints[:completed] = summarize_tournament(tournament)
|
||||
|
||||
result(session:, tournament:, checkpoints:, stop_at:)
|
||||
result(session: session, tournament: tournament, checkpoints: checkpoints, stop_at: stop_at)
|
||||
end
|
||||
|
||||
def run_group_direct_comparison_tiebreak(case_name:, stop_at: :group_scores)
|
||||
|
|
@ -158,8 +189,8 @@ module TurniereE2E
|
|||
created: summarize_tournament(tournament)
|
||||
}
|
||||
if stop_at == :created
|
||||
return result(session:, tournament:, checkpoints:,
|
||||
stop_at:)
|
||||
return result(session: session, tournament: tournament, checkpoints: checkpoints,
|
||||
stop_at: stop_at)
|
||||
end
|
||||
|
||||
group = group_stage_of!(tournament).fetch(:groups).first
|
||||
|
|
@ -172,16 +203,68 @@ module TurniereE2E
|
|||
}
|
||||
|
||||
tournament = fetch_tournament!(tournament.fetch(:id))
|
||||
result(session:, tournament:, checkpoints:, stop_at:)
|
||||
result(session: session, tournament: tournament, checkpoints: checkpoints, stop_at: stop_at)
|
||||
end
|
||||
|
||||
def run_transaction_log_history(stop_at: :transaction_log)
|
||||
session = register_session!
|
||||
tournament = create_tournament!(
|
||||
create_group_stage_payload(group_count: 1, teams_per_group: 2, playoff_teams_amount: 1)
|
||||
)
|
||||
|
||||
checkpoints = {
|
||||
created: summarize_tournament(tournament)
|
||||
}
|
||||
|
||||
group_stage = group_stage_of!(tournament)
|
||||
group = group_stage.fetch(:groups).first
|
||||
match = group.fetch(:matches).first
|
||||
team_ids = match.fetch(:match_scores).map { |score| score.dig(:team, :id) }
|
||||
|
||||
play_match_with_points!(
|
||||
match,
|
||||
points_by_team_name: {
|
||||
'G1-S1' => 1,
|
||||
'G1-S2' => 1
|
||||
}
|
||||
)
|
||||
checkpoints[:drawn_group_match] = fetch_match!(match.fetch(:id))
|
||||
|
||||
failed_playoff_start = client.patch("/stages/#{group_stage.fetch(:id)}", body: { state: 'finished' })
|
||||
expect_status!(failed_playoff_start, 422, 'finish group stage without decider')
|
||||
checkpoints[:failed_playoff_start] = failed_playoff_start.fetch(:json)
|
||||
|
||||
decider = create_decider_match!(group.fetch(:id), team_ids)
|
||||
play_match_with_hidden_points!(
|
||||
decider,
|
||||
hidden_points_by_team_name: {
|
||||
'G1-S1' => 2,
|
||||
'G1-S2' => 1
|
||||
}
|
||||
)
|
||||
checkpoints[:decider_finished] = fetch_match!(decider.fetch(:id))
|
||||
|
||||
finish_stage!(group_stage.fetch(:id))
|
||||
tournament = fetch_tournament!(tournament.fetch(:id))
|
||||
checkpoints[:playoffs_created] = summarize_tournament(tournament)
|
||||
|
||||
transaction_log = get!("/tournaments/#{tournament.fetch(:id)}/transaction_log")
|
||||
checkpoints[:transaction_log] = {
|
||||
actions: transaction_log.map { |entry| entry.fetch(:action) },
|
||||
sequences: transaction_log.map { |entry| entry.fetch(:sequence) },
|
||||
messages: transaction_log.map { |entry| entry.fetch(:message) }
|
||||
}
|
||||
|
||||
result(session: session, tournament: tournament, checkpoints: checkpoints, stop_at: stop_at)
|
||||
end
|
||||
|
||||
def run_group_stage_render_profile(group_count:, teams_per_group:, playoff_teams_amount:, stop_at: :profiled_show)
|
||||
session = register_session!
|
||||
tournament = create_tournament!(
|
||||
create_group_stage_payload(
|
||||
group_count:,
|
||||
teams_per_group:,
|
||||
playoff_teams_amount:
|
||||
group_count: group_count,
|
||||
teams_per_group: teams_per_group,
|
||||
playoff_teams_amount: playoff_teams_amount
|
||||
)
|
||||
)
|
||||
|
||||
|
|
@ -189,18 +272,18 @@ module TurniereE2E
|
|||
created: summarize_tournament(tournament)
|
||||
}
|
||||
if stop_at == :created
|
||||
return result(session:, tournament:, checkpoints:,
|
||||
stop_at:)
|
||||
return result(session: session, tournament: tournament, checkpoints: checkpoints,
|
||||
stop_at: stop_at)
|
||||
end
|
||||
|
||||
profiled_show = fetch_tournament_profile!(tournament.fetch(:id))
|
||||
checkpoints[:profiled_show] = profiled_show
|
||||
|
||||
result(session:, tournament: profiled_show.fetch(:tournament), checkpoints:, stop_at:)
|
||||
result(session: session, tournament: profiled_show.fetch(:tournament), checkpoints: checkpoints, stop_at: stop_at)
|
||||
end
|
||||
|
||||
def wait_for_healthcheck!(timeout_seconds: 30)
|
||||
started_at = Time.zone.now
|
||||
started_at = Process.clock_gettime(Process::CLOCK_MONOTONIC)
|
||||
loop do
|
||||
begin
|
||||
response = client.get('/healthz')
|
||||
|
|
@ -209,7 +292,8 @@ module TurniereE2E
|
|||
nil
|
||||
end
|
||||
|
||||
raise "healthcheck did not become ready within #{timeout_seconds}s" if Time.zone.now - started_at > timeout_seconds
|
||||
elapsed = Process.clock_gettime(Process::CLOCK_MONOTONIC) - started_at
|
||||
raise "healthcheck did not become ready within #{timeout_seconds}s" if elapsed > timeout_seconds
|
||||
|
||||
sleep 0.5
|
||||
end
|
||||
|
|
@ -223,11 +307,11 @@ module TurniereE2E
|
|||
unique = SecureRandom.hex(6)
|
||||
email = "e2e-#{unique}@example.com"
|
||||
username = "e2e-#{unique}"
|
||||
client.register!(email:, password: DEFAULT_PASSWORD, username:)
|
||||
return { email:, username: } if client.authenticated?
|
||||
client.register!(email: email, password: DEFAULT_PASSWORD, username: username)
|
||||
return { email: email, username: username } if client.authenticated?
|
||||
|
||||
login_response = client.login!(email:, password: DEFAULT_PASSWORD)
|
||||
return { email:, username: } if login_response.fetch(:status) == 200 && client.authenticated?
|
||||
login_response = client.login!(email: email, password: DEFAULT_PASSWORD)
|
||||
return { email: email, username: username } if login_response.fetch(:status) == 200 && client.authenticated?
|
||||
|
||||
raise ApiError.new(
|
||||
'authentication bootstrap failed; provide a confirmed E2E user via TURNIERE_E2E_EMAIL and TURNIERE_E2E_PASSWORD',
|
||||
|
|
@ -236,6 +320,20 @@ module TurniereE2E
|
|||
)
|
||||
end
|
||||
|
||||
def login_with_existing_credentials!
|
||||
login_response = client.login!(email: @email, password: @password)
|
||||
expect_status!(login_response, 200, "login #{@email}")
|
||||
unless client.authenticated?
|
||||
raise ApiError.new(
|
||||
'login succeeded but no auth headers were returned',
|
||||
status: 200,
|
||||
body: login_response.fetch(:json)
|
||||
)
|
||||
end
|
||||
|
||||
{ email: @email, username: @username || @email }
|
||||
end
|
||||
|
||||
def create_tournament!(payload)
|
||||
response = client.post('/tournaments', body: payload)
|
||||
expect_status!(response, 201, 'create tournament')
|
||||
|
|
@ -249,19 +347,19 @@ module TurniereE2E
|
|||
end
|
||||
|
||||
def get!(path, params: nil)
|
||||
response = get_response!(path, params:)
|
||||
response = get_response!(path, params: params)
|
||||
expect_status!(response, 200, "get #{path}")
|
||||
response.fetch(:json)
|
||||
end
|
||||
|
||||
def get_response!(path, params: nil)
|
||||
response = client.get(path, params:)
|
||||
response = client.get(path, params: params)
|
||||
expect_status!(response, 200, "get #{path}")
|
||||
response
|
||||
end
|
||||
|
||||
def patch!(path, body:)
|
||||
response = client.patch(path, body:)
|
||||
response = client.patch(path, body: body)
|
||||
expect_status!(response, 200, "patch #{path}")
|
||||
response.fetch(:json)
|
||||
end
|
||||
|
|
@ -315,7 +413,7 @@ module TurniereE2E
|
|||
refreshed_match = fetch_match!(match.fetch(:id))
|
||||
refreshed_match.fetch(:match_scores).sort_by { |score| score.fetch(:id) }.each do |score|
|
||||
points = score.dig(:team, :name) == winner_name ? 10 : 1
|
||||
updated_score = client.patch("/match_scores/#{score.fetch(:id)}", body: { points: })
|
||||
updated_score = client.patch("/match_scores/#{score.fetch(:id)}", body: { points: points })
|
||||
expect_status!(updated_score, 200, "update match_score #{score.fetch(:id)}")
|
||||
end
|
||||
|
||||
|
|
@ -345,7 +443,7 @@ module TurniereE2E
|
|||
points = points_by_team_name.fetch(team_name) do
|
||||
raise "missing score for #{team_name.inspect} in match #{match.fetch(:id)}"
|
||||
end
|
||||
updated_score = client.patch("/match_scores/#{score.fetch(:id)}", body: { points: })
|
||||
updated_score = client.patch("/match_scores/#{score.fetch(:id)}", body: { points: points })
|
||||
expect_status!(updated_score, 200, "update match_score #{score.fetch(:id)}")
|
||||
end
|
||||
|
||||
|
|
@ -365,6 +463,36 @@ module TurniereE2E
|
|||
}
|
||||
end
|
||||
|
||||
def play_match_with_hidden_points!(match, hidden_points_by_team_name:, expected_finish_status: 200)
|
||||
started = client.patch("/matches/#{match.fetch(:id)}", body: { state: 'in_progress' })
|
||||
expect_status!(started, 200, "start match #{match.fetch(:id)}")
|
||||
|
||||
refreshed_match = fetch_match!(match.fetch(:id))
|
||||
refreshed_match.fetch(:match_scores).each do |score|
|
||||
team_name = score.dig(:team, :name)
|
||||
hidden_points = hidden_points_by_team_name.fetch(team_name) do
|
||||
raise "missing hidden score for #{team_name.inspect} in match #{match.fetch(:id)}"
|
||||
end
|
||||
updated_score = client.patch("/match_scores/#{score.fetch(:id)}", body: { hidden_points: hidden_points })
|
||||
expect_status!(updated_score, 200, "update hidden match_score #{score.fetch(:id)}")
|
||||
end
|
||||
|
||||
finished = client.patch("/matches/#{match.fetch(:id)}", body: { state: 'finished' })
|
||||
expect_status!(finished, expected_finish_status, "finish match #{match.fetch(:id)}")
|
||||
|
||||
{
|
||||
match_id: match.fetch(:id),
|
||||
status: finished.fetch(:status),
|
||||
body: finished.fetch(:json)
|
||||
}
|
||||
end
|
||||
|
||||
def create_decider_match!(group_id, team_ids)
|
||||
response = client.post("/groups/#{group_id}/matches", body: { team_ids: team_ids })
|
||||
expect_status!(response, 201, "create decider match for group #{group_id}")
|
||||
response.fetch(:json)
|
||||
end
|
||||
|
||||
def fetch_match!(match_id)
|
||||
response = client.get("/matches/#{match_id}")
|
||||
expect_status!(response, 200, "fetch match #{match_id}")
|
||||
|
|
@ -377,6 +505,12 @@ module TurniereE2E
|
|||
response.fetch(:json)
|
||||
end
|
||||
|
||||
def delete_playoffs!(tournament_id)
|
||||
response = client.delete("/tournaments/#{tournament_id}/playoffs")
|
||||
expect_status!(response, 200, "delete playoffs for tournament #{tournament_id}")
|
||||
response.fetch(:json)
|
||||
end
|
||||
|
||||
def group_stage_of!(tournament)
|
||||
tournament.fetch(:stages).find { |stage| stage.fetch(:level) == -1 } || raise('group stage not found')
|
||||
end
|
||||
|
|
@ -431,7 +565,35 @@ module TurniereE2E
|
|||
match = matches_by_teams.fetch(team_names.sort) do
|
||||
raise "match for #{team_names.inspect} not found"
|
||||
end
|
||||
play_match_with_points!(match, points_by_team_name:)
|
||||
play_match_with_points!(match, points_by_team_name: points_by_team_name)
|
||||
end
|
||||
end
|
||||
|
||||
def apply_group_score_round!(tournament, scores_by_team_name)
|
||||
group_stage_of!(tournament).fetch(:groups).each do |group|
|
||||
group.fetch(:matches).each do |match|
|
||||
points_by_team_name = match.fetch(:match_scores).to_h do |score|
|
||||
team_name = score.dig(:team, :name)
|
||||
[team_name, scores_by_team_name.fetch(team_name)]
|
||||
end
|
||||
|
||||
if match.fetch(:state) == 'not_started'
|
||||
play_match_with_points!(match, points_by_team_name: points_by_team_name)
|
||||
else
|
||||
update_match_scores!(match, points_by_team_name: points_by_team_name)
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
def update_match_scores!(match, points_by_team_name:)
|
||||
refreshed_match = fetch_match!(match.fetch(:id))
|
||||
refreshed_match.fetch(:match_scores).each do |score|
|
||||
team_name = score.dig(:team, :name)
|
||||
updated_score = client.patch("/match_scores/#{score.fetch(:id)}", body: {
|
||||
points: points_by_team_name.fetch(team_name)
|
||||
})
|
||||
expect_status!(updated_score, 200, "update match_score #{score.fetch(:id)}")
|
||||
end
|
||||
end
|
||||
|
||||
|
|
@ -464,11 +626,40 @@ module TurniereE2E
|
|||
description: 'HTTP lifecycle E2E tournament',
|
||||
public: false,
|
||||
group_stage: true,
|
||||
playoff_teams_amount:,
|
||||
teams:
|
||||
playoff_teams_amount: playoff_teams_amount,
|
||||
teams: teams
|
||||
}
|
||||
end
|
||||
|
||||
def playoff_recreation_rounds
|
||||
[
|
||||
{
|
||||
scores_by_team_name: {
|
||||
'G1-S1' => 3,
|
||||
'G1-S2' => 1,
|
||||
'G2-S1' => 3,
|
||||
'G2-S2' => 1
|
||||
}
|
||||
},
|
||||
{
|
||||
scores_by_team_name: {
|
||||
'G1-S1' => 0,
|
||||
'G1-S2' => 4,
|
||||
'G2-S1' => 0,
|
||||
'G2-S2' => 4
|
||||
}
|
||||
},
|
||||
{
|
||||
scores_by_team_name: {
|
||||
'G1-S1' => 5,
|
||||
'G1-S2' => 2,
|
||||
'G2-S1' => 2,
|
||||
'G2-S2' => 5
|
||||
}
|
||||
}
|
||||
]
|
||||
end
|
||||
|
||||
def create_playoff_only_payload(team_count:)
|
||||
{
|
||||
name: "HTTP E2E Playoff Only #{team_count} #{SecureRandom.hex(3)}",
|
||||
|
|
@ -559,6 +750,19 @@ module TurniereE2E
|
|||
}
|
||||
end
|
||||
|
||||
def playoff_matchups(tournament)
|
||||
entry_stage = tournament.fetch(:stages)
|
||||
.reject { |stage| stage.fetch(:level) == -1 }
|
||||
.max_by { |stage| stage.fetch(:level) }
|
||||
entry_stage.fetch(:matches)
|
||||
.sort_by { |match| match.fetch(:position) }
|
||||
.map do |match|
|
||||
match.fetch(:match_scores)
|
||||
.map { |score| score.dig(:team, :name) }
|
||||
.sort
|
||||
end
|
||||
end
|
||||
|
||||
def fetch_tournament_profile!(tournament_id)
|
||||
started_at = Process.clock_gettime(Process::CLOCK_MONOTONIC)
|
||||
response = get_response!("/tournaments/#{tournament_id}", params: { profile: 'true' })
|
||||
|
|
@ -574,7 +778,7 @@ module TurniereE2E
|
|||
end
|
||||
|
||||
def parse_server_timing(header)
|
||||
return {} if header.blank?
|
||||
return {} if header.to_s.empty?
|
||||
|
||||
header.split(',').each_with_object({}) do |entry, sections|
|
||||
name, duration = entry.strip.match(/\A([^;]+);dur=([0-9.]+)\z/)&.captures
|
||||
|
|
@ -586,13 +790,17 @@ module TurniereE2E
|
|||
|
||||
def result(session:, tournament:, checkpoints:, stop_at:)
|
||||
{
|
||||
session:,
|
||||
session: session,
|
||||
stop_at: stop_at.to_s,
|
||||
tournament: summarize_tournament(tournament),
|
||||
checkpoints:
|
||||
checkpoints: checkpoints
|
||||
}
|
||||
end
|
||||
|
||||
def checkpoint_result(session, tournament, checkpoints, stop_at)
|
||||
result(session: session, tournament: tournament, checkpoints: checkpoints, stop_at: stop_at)
|
||||
end
|
||||
|
||||
def expect_status!(response, expected_status, action)
|
||||
return if response.fetch(:status) == expected_status
|
||||
|
||||
|
|
@ -604,14 +812,3 @@ module TurniereE2E
|
|||
end
|
||||
end
|
||||
end
|
||||
|
||||
def login_with_existing_credentials!
|
||||
login_response = client.login!(email: @email, password: @password)
|
||||
expect_status!(login_response, 200, "login #{@email}")
|
||||
unless client.authenticated?
|
||||
raise ApiError.new('login succeeded but no auth headers were returned', status: 200,
|
||||
body: login_response.fetch(:json))
|
||||
end
|
||||
|
||||
{ email: @email, username: @username || @email }
|
||||
end
|
||||
|
|
|
|||
|
|
@ -0,0 +1,104 @@
|
|||
#!/usr/bin/env ruby
|
||||
# frozen_string_literal: true
|
||||
|
||||
require 'json'
|
||||
require 'optparse'
|
||||
|
||||
require_relative 'lib/load_test_runner'
|
||||
|
||||
def parse_client_list(value)
|
||||
return [] if value.nil? || value.strip == '' || value.strip.casecmp('none').zero?
|
||||
|
||||
value.split(',').map { |entry| Integer(entry.strip) }
|
||||
end
|
||||
|
||||
options = {
|
||||
base_url: ENV.fetch('TURNIERE_E2E_BASE_URL', 'http://127.0.0.1:3000'),
|
||||
email: ENV['TURNIERE_E2E_EMAIL'],
|
||||
password: ENV['TURNIERE_E2E_PASSWORD'],
|
||||
username: ENV['TURNIERE_E2E_USERNAME'],
|
||||
tournament_id: ENV['TURNIERE_LOAD_TOURNAMENT_ID'],
|
||||
http_clients: parse_client_list(ENV.fetch('TURNIERE_LOAD_HTTP_CLIENTS', '1,10,50,100')),
|
||||
websocket_clients: parse_client_list(ENV.fetch('TURNIERE_LOAD_WEBSOCKET_CLIENTS', '1,10,50,100,200')),
|
||||
http_requests_per_client: Integer(ENV.fetch('TURNIERE_LOAD_HTTP_REQUESTS_PER_CLIENT', '3')),
|
||||
websocket_hold_seconds: Float(ENV.fetch('TURNIERE_LOAD_WEBSOCKET_HOLD_SECONDS', '1.0')),
|
||||
websocket_connect_batch_size: Integer(ENV.fetch('TURNIERE_LOAD_WEBSOCKET_CONNECT_BATCH_SIZE', '25')),
|
||||
group_count: Integer(ENV.fetch('TURNIERE_LOAD_GROUP_COUNT', '4')),
|
||||
teams_per_group: Integer(ENV.fetch('TURNIERE_LOAD_TEAMS_PER_GROUP', '4')),
|
||||
playoff_teams_amount: Integer(ENV.fetch('TURNIERE_LOAD_PLAYOFF_TEAMS', '8')),
|
||||
format: ENV.fetch('TURNIERE_LOAD_FORMAT', 'text')
|
||||
}
|
||||
|
||||
# rubocop:disable Metrics/BlockLength
|
||||
OptionParser.new do |parser|
|
||||
parser.banner = 'Usage: ruby e2e/load_test.rb [options]'
|
||||
|
||||
parser.on('--base-url URL', 'API base URL') { |value| options[:base_url] = value }
|
||||
parser.on('--email EMAIL', 'Confirmed E2E user email') { |value| options[:email] = value }
|
||||
parser.on('--password PASSWORD', 'Confirmed E2E user password') { |value| options[:password] = value }
|
||||
parser.on('--username USERNAME', 'Confirmed E2E user username') { |value| options[:username] = value }
|
||||
parser.on('--tournament-id ID', 'Existing tournament ID instead of creating one') do |value|
|
||||
options[:tournament_id] = value
|
||||
end
|
||||
parser.on('--http-clients LIST', 'Comma-separated HTTP client levels, or none') do |value|
|
||||
options[:http_clients] = parse_client_list(value)
|
||||
end
|
||||
parser.on('--websocket-clients LIST', 'Comma-separated websocket client levels, or none') do |value|
|
||||
options[:websocket_clients] = parse_client_list(value)
|
||||
end
|
||||
parser.on('--http-requests-per-client COUNT', Integer, 'GET requests per HTTP client') do |value|
|
||||
options[:http_requests_per_client] = value
|
||||
end
|
||||
parser.on('--websocket-hold-seconds SECONDS', Float, 'Seconds to hold websocket clients open') do |value|
|
||||
options[:websocket_hold_seconds] = value
|
||||
end
|
||||
parser.on('--websocket-connect-batch-size COUNT', Integer, 'Concurrent websocket handshakes per batch') do |value|
|
||||
options[:websocket_connect_batch_size] = value
|
||||
end
|
||||
parser.on('--group-count COUNT', Integer, 'Groups in generated tournament') { |value| options[:group_count] = value }
|
||||
parser.on('--teams-per-group COUNT', Integer, 'Teams per generated group') do |value|
|
||||
options[:teams_per_group] = value
|
||||
end
|
||||
parser.on('--playoff-teams-amount COUNT', Integer, 'Playoff teams in generated tournament') do |value|
|
||||
options[:playoff_teams_amount] = value
|
||||
end
|
||||
parser.on('--format FORMAT', 'text or json') { |value| options[:format] = value }
|
||||
end.parse!
|
||||
# rubocop:enable Metrics/BlockLength
|
||||
|
||||
runner_options = options.dup
|
||||
runner_options.delete(:format)
|
||||
runner = TurniereE2E::LoadTestRunner.new(**runner_options)
|
||||
result = runner.run
|
||||
|
||||
if options[:format] == 'json'
|
||||
puts JSON.pretty_generate(result)
|
||||
exit
|
||||
end
|
||||
|
||||
puts "Load test base_url=#{result.fetch(:base_url)} tournament_id=#{result.dig(:tournament, :id)}"
|
||||
|
||||
result.fetch(:http).each do |level|
|
||||
puts(
|
||||
"HTTP #{level.fetch(:clients)} clients: " \
|
||||
"#{level.fetch(:ok)}/#{level.fetch(:requests)} ok, errors=#{level.fetch(:errors)}, " \
|
||||
"p50=#{level.fetch(:p50_ms)}ms, p95=#{level.fetch(:p95_ms)}ms, " \
|
||||
"max=#{level.fetch(:max_ms)}ms, rps=#{level.fetch(:requests_per_second)}"
|
||||
)
|
||||
end
|
||||
|
||||
result.fetch(:websocket).each do |level|
|
||||
puts(
|
||||
"WS #{level.fetch(:clients)} clients: " \
|
||||
"opened=#{level.fetch(:opened)}, errors=#{level.fetch(:errors)}, " \
|
||||
"p50_open=#{level.fetch(:p50_open_ms)}ms, p95_open=#{level.fetch(:p95_open_ms)}ms, " \
|
||||
"connections_metric=#{level.fetch(:connection_metric)}"
|
||||
)
|
||||
end
|
||||
|
||||
metrics = result.fetch(:metrics)
|
||||
puts(
|
||||
"Metrics: action_cable_connections=#{metrics.fetch(:action_cable_connections)}, " \
|
||||
"action_cable_connections_total=#{metrics.fetch(:action_cable_connections_total)}, " \
|
||||
"ruby_threads=#{metrics.fetch(:ruby_threads)}"
|
||||
)
|
||||
|
|
@ -52,6 +52,8 @@ result = case scenario
|
|||
runner.run_group_stage_main_usecase(stop_at: options[:stop_at] || :final_finish_attempted)
|
||||
when 'group_stage_intermediate_round'
|
||||
runner.run_group_stage_intermediate_round(stop_at: options[:stop_at] || :final_finish_attempted)
|
||||
when 'group_stage_playoff_recreation'
|
||||
runner.run_group_stage_playoff_recreation(stop_at: options[:stop_at] || :third_playoffs_created)
|
||||
when 'playoff_only'
|
||||
runner.run_playoff_only(team_count: options[:team_count], stop_at: options[:stop_at] || :completed)
|
||||
when 'group_stage_render_profile'
|
||||
|
|
|
|||
|
|
@ -4,5 +4,9 @@ module AuthHelpers
|
|||
def apply_authentication_headers_for(user)
|
||||
user_headers = user.create_new_auth_token
|
||||
request.headers.merge!(user_headers)
|
||||
return unless respond_to?(:controller)
|
||||
|
||||
allow(controller).to receive(:authenticate_user!).and_return(true)
|
||||
allow(controller).to receive(:current_user).and_return(user)
|
||||
end
|
||||
end
|
||||
|
|
|
|||
|
|
@ -0,0 +1,148 @@
|
|||
# frozen_string_literal: true
|
||||
|
||||
require 'erb'
|
||||
require 'yaml'
|
||||
|
||||
RSpec.describe 'runtime scaling configuration' do
|
||||
around do |example|
|
||||
with_env(
|
||||
'DATABASE_URL' => nil,
|
||||
'POSTGRES_URL' => nil,
|
||||
'PORT' => nil,
|
||||
'RAILS_DB_POOL' => nil,
|
||||
'RAILS_ENV' => nil,
|
||||
'RAILS_MAX_THREADS' => nil,
|
||||
'RAILS_MIN_THREADS' => nil,
|
||||
'SIDEKIQ_CONCURRENCY' => nil,
|
||||
'WEB_CONCURRENCY' => nil
|
||||
) { example.run }
|
||||
end
|
||||
|
||||
it 'configures Puma workers and threads from environment variables' do
|
||||
with_env(
|
||||
'PORT' => '4001',
|
||||
'RAILS_ENV' => 'production',
|
||||
'RAILS_MAX_THREADS' => '7',
|
||||
'RAILS_MIN_THREADS' => '2',
|
||||
'WEB_CONCURRENCY' => '4'
|
||||
) do
|
||||
config = evaluate_puma_config
|
||||
|
||||
expect(config.fetch(:threads)).to eq(%w[2 7])
|
||||
expect(config.fetch(:workers)).to eq('4')
|
||||
expect(config.fetch(:preload_app)).to be(true)
|
||||
expect(config.fetch(:port)).to eq('4001')
|
||||
expect(config.fetch(:environment)).to eq('production')
|
||||
expect(config.fetch(:plugin)).to eq(:tmp_restart)
|
||||
end
|
||||
end
|
||||
|
||||
it 'uses safe Puma defaults' do
|
||||
config = evaluate_puma_config
|
||||
|
||||
expect(config.fetch(:threads)).to eq([5, 5])
|
||||
expect(config.fetch(:workers)).to eq(2)
|
||||
expect(config.fetch(:port)).to eq(3000)
|
||||
expect(config.fetch(:environment)).to eq('production')
|
||||
end
|
||||
|
||||
it 'configures the production database pool from RAILS_DB_POOL' do
|
||||
with_env(
|
||||
'DATABASE_URL' => 'postgres://turniere:secret@postgres/turniere',
|
||||
'RAILS_DB_POOL' => '9',
|
||||
'RAILS_MAX_THREADS' => '7'
|
||||
) do
|
||||
config = database_config.fetch('production')
|
||||
|
||||
expect(config.fetch('adapter')).to eq('postgresql')
|
||||
expect(config.fetch('encoding')).to eq('unicode')
|
||||
expect(config.fetch('url')).to eq('postgres://turniere:secret@postgres/turniere')
|
||||
expect(config.fetch('pool')).to eq(9)
|
||||
end
|
||||
end
|
||||
|
||||
it 'falls back to RAILS_MAX_THREADS for the database pool and POSTGRES_URL for legacy deployments' do
|
||||
with_env(
|
||||
'POSTGRES_URL' => 'postgres://turniere:secret@legacy-postgres/turniere',
|
||||
'RAILS_MAX_THREADS' => '6'
|
||||
) do
|
||||
config = database_config.fetch('production')
|
||||
|
||||
expect(config.fetch('url')).to eq('postgres://turniere:secret@legacy-postgres/turniere')
|
||||
expect(config.fetch('pool')).to eq(6)
|
||||
end
|
||||
end
|
||||
|
||||
it 'configures Sidekiq concurrency from SIDEKIQ_CONCURRENCY' do
|
||||
with_env('SIDEKIQ_CONCURRENCY' => '10') do
|
||||
expect(sidekiq_config).to include(':concurrency: 10')
|
||||
end
|
||||
|
||||
expect(sidekiq_config).to include(':concurrency: 5')
|
||||
end
|
||||
|
||||
def evaluate_puma_config
|
||||
probe = puma_config_probe_class.new
|
||||
probe.instance_eval(File.read(File.join(root, 'config/puma.rb')), File.join(root, 'config/puma.rb'))
|
||||
probe.config
|
||||
end
|
||||
|
||||
def database_config
|
||||
YAML.safe_load(ERB.new(File.read(File.join(root, 'config/database.yml'))).result, aliases: true)
|
||||
end
|
||||
|
||||
def sidekiq_config
|
||||
ERB.new(File.read(File.join(root, 'config/sidekiq.yml'))).result
|
||||
end
|
||||
|
||||
def with_env(values)
|
||||
previous = values.to_h { |key, _value| [key, ENV.fetch(key, nil)] }
|
||||
values.each do |key, value|
|
||||
value.nil? ? ENV.delete(key) : ENV[key] = value
|
||||
end
|
||||
|
||||
yield
|
||||
ensure
|
||||
previous.each do |key, value|
|
||||
value.nil? ? ENV.delete(key) : ENV[key] = value
|
||||
end
|
||||
end
|
||||
|
||||
def root
|
||||
File.expand_path('../..', __dir__)
|
||||
end
|
||||
|
||||
def puma_config_probe_class
|
||||
Class.new do
|
||||
attr_reader :config
|
||||
|
||||
def initialize
|
||||
@config = {}
|
||||
end
|
||||
|
||||
def threads(min_threads, max_threads)
|
||||
@config[:threads] = [min_threads, max_threads]
|
||||
end
|
||||
|
||||
def workers(count)
|
||||
@config[:workers] = count
|
||||
end
|
||||
|
||||
def preload_app!
|
||||
@config[:preload_app] = true
|
||||
end
|
||||
|
||||
def port(value)
|
||||
@config[:port] = value
|
||||
end
|
||||
|
||||
def environment(value)
|
||||
@config[:environment] = value
|
||||
end
|
||||
|
||||
def plugin(name)
|
||||
@config[:plugin] = name
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
|
|
@ -0,0 +1,54 @@
|
|||
# frozen_string_literal: true
|
||||
|
||||
require 'rails_helper'
|
||||
|
||||
RSpec.describe AccountsController, type: :controller do
|
||||
describe 'GET #show' do
|
||||
let(:account) { create(:user, username: 'player-one') }
|
||||
let(:public_team) { create(:team, name: 'Public Team', players: [account]) }
|
||||
let(:private_team) { create(:team, name: 'Private Team', players: [account]) }
|
||||
let(:public_tournament) { create(:tournament, public: true, teams: [public_team]) }
|
||||
let(:private_tournament) { create(:tournament, public: false, teams: [private_team]) }
|
||||
let(:created_public_tournament) { create(:tournament, user: account, public: true) }
|
||||
let(:created_private_tournament) { create(:tournament, user: account, public: false) }
|
||||
|
||||
before do
|
||||
public_tournament
|
||||
private_tournament
|
||||
created_public_tournament
|
||||
created_private_tournament
|
||||
end
|
||||
|
||||
it 'returns public account profile information' do
|
||||
get :show, params: { id: account.to_param }
|
||||
|
||||
body = deserialize_response(response)
|
||||
expect(response).to be_successful
|
||||
expect(body[:id]).to eq(account.id)
|
||||
expect(body[:name]).to eq('player-one')
|
||||
expect(body[:username]).to eq('player-one')
|
||||
expect(body[:teams].map { |team| team[:id] }).to eq([public_team.id])
|
||||
expect(body[:tournaments].map { |tournament| tournament[:id] }).to eq([public_tournament.id])
|
||||
expect(body[:created_tournaments].map { |tournament| tournament[:id] }).to eq([created_public_tournament.id])
|
||||
end
|
||||
|
||||
it 'returns private profile information to the account owner' do
|
||||
apply_authentication_headers_for account
|
||||
|
||||
get :show, params: { id: account.to_param }
|
||||
|
||||
body = deserialize_response(response)
|
||||
expect(body[:teams].map { |team| team[:id] }).to match_array([public_team.id, private_team.id])
|
||||
expect(body[:tournaments].map { |tournament| tournament[:id] })
|
||||
.to match_array([public_tournament.id, private_tournament.id])
|
||||
expect(body[:created_tournaments].map { |tournament| tournament[:id] })
|
||||
.to match_array([created_public_tournament.id, created_private_tournament.id])
|
||||
end
|
||||
|
||||
it 'returns not found for unknown accounts' do
|
||||
get :show, params: { id: User.maximum(:id).to_i + 1 }
|
||||
|
||||
expect(response).to have_http_status(:not_found)
|
||||
end
|
||||
end
|
||||
end
|
||||
|
|
@ -77,9 +77,9 @@ RSpec.describe BeamersController, type: :controller do
|
|||
before(:each) { apply_authentication_headers_for @owner }
|
||||
|
||||
it 'creates a new beamer' do
|
||||
expect do
|
||||
expect {
|
||||
post :create, params: { tournament_id: @tournament.to_param }.merge(valid_params)
|
||||
end.to change(@tournament.beamers, :count).by(1)
|
||||
}.to change(@tournament.beamers, :count).by(1)
|
||||
expect(response).to have_http_status(:created)
|
||||
end
|
||||
|
||||
|
|
@ -95,14 +95,14 @@ RSpec.describe BeamersController, type: :controller do
|
|||
post :create, params: {
|
||||
tournament_id: @tournament.to_param, name: 'bad', display_state: 'invalid'
|
||||
}
|
||||
expect(response).to have_http_status(:unprocessable_entity)
|
||||
expect(response).to have_http_status(:unprocessable_content)
|
||||
end
|
||||
|
||||
it 'rejects duplicate name in same tournament' do
|
||||
post :create, params: {
|
||||
tournament_id: @tournament.to_param, name: @beamer.name, display_state: 'during_game'
|
||||
}
|
||||
expect(response).to have_http_status(:unprocessable_entity)
|
||||
expect(response).to have_http_status(:unprocessable_content)
|
||||
end
|
||||
end
|
||||
|
||||
|
|
@ -157,7 +157,7 @@ RSpec.describe BeamersController, type: :controller do
|
|||
tournament_id: @tournament.to_param, id: @beamer.to_param,
|
||||
display_state: 'bogus'
|
||||
}
|
||||
expect(response).to have_http_status(:unprocessable_entity)
|
||||
expect(response).to have_http_status(:unprocessable_content)
|
||||
end
|
||||
end
|
||||
|
||||
|
|
@ -194,11 +194,11 @@ RSpec.describe BeamersController, type: :controller do
|
|||
before(:each) { apply_authentication_headers_for @owner }
|
||||
|
||||
it 'deletes the beamer' do
|
||||
expect do
|
||||
expect {
|
||||
delete :destroy, params: {
|
||||
tournament_id: @tournament.to_param, id: @beamer.to_param
|
||||
}
|
||||
end.to change(@tournament.beamers, :count).by(-1)
|
||||
}.to change(@tournament.beamers, :count).by(-1)
|
||||
expect(response).to have_http_status(:no_content)
|
||||
end
|
||||
end
|
||||
|
|
|
|||
|
|
@ -24,18 +24,17 @@ RSpec.describe GroupsController, type: :controller do
|
|||
|
||||
it 'returns ranking_decision metadata for group scores' do
|
||||
tournament = @group.stage.tournament
|
||||
tournament.update!(playoff_teams_amount: 2, instant_finalists_amount: 2,
|
||||
intermediate_round_participants_amount: 0)
|
||||
tournament.update!(playoff_teams_amount: 2, instant_finalists_amount: 2, intermediate_round_participants_amount: 0)
|
||||
@group.stage.update!(level: -1)
|
||||
@group.matches.destroy_all
|
||||
@group.group_scores.destroy_all
|
||||
|
||||
team_a = create(:team, tournament:, name: 'A')
|
||||
team_b = create(:team, tournament:, name: 'B')
|
||||
team_a = create(:team, tournament: tournament, name: 'A')
|
||||
team_b = create(:team, tournament: tournament, name: 'B')
|
||||
|
||||
create(:group_match, group: @group, state: :finished).tap do |match|
|
||||
create(:match_score, match:, team: team_a, points: 3)
|
||||
create(:match_score, match:, team: team_b, points: 1)
|
||||
create(:match_score, match: match, team: team_a, points: 3)
|
||||
create(:match_score, match: match, team: team_b, points: 1)
|
||||
end
|
||||
|
||||
score_a = create(:group_score, group: @group, team: team_a, group_points: 3, scored_points: 3, received_points: 1)
|
||||
|
|
|
|||
|
|
@ -51,6 +51,23 @@ RSpec.describe MatchScoresController, type: :controller do
|
|||
expect(body[:points]).to eq(valid_update[:points])
|
||||
expect(body[:hidden_points]).to eq(valid_update[:hidden_points])
|
||||
end
|
||||
|
||||
it 'writes a transaction log entry for the score change' do
|
||||
expect(TournamentLiveBroadcast).to receive(:call).with(@match_score.tournament.id)
|
||||
|
||||
expect do
|
||||
put :update, params: { id: @match_score.to_param }.merge(valid_update)
|
||||
end.to change { @match_score.tournament.transaction_log_entries.count }.by(1)
|
||||
|
||||
entry = @match_score.tournament.transaction_log_entries.last
|
||||
expect(entry.action).to eq('match_score.updated')
|
||||
expect(entry.message).to include("changed matchscore #{@match_score.id}")
|
||||
expect(entry.metadata['match_score_id']).to eq(@match_score.id)
|
||||
expect(entry.metadata['changes']['points']).to eq(
|
||||
'from' => @match_score.points,
|
||||
'to' => valid_update[:points]
|
||||
)
|
||||
end
|
||||
end
|
||||
|
||||
context 'when match_score update fails' do
|
||||
|
|
@ -62,7 +79,7 @@ RSpec.describe MatchScoresController, type: :controller do
|
|||
|
||||
it 'returns unprocessable entity' do
|
||||
put :update, params: { id: @match_score.to_param }.merge(valid_update)
|
||||
expect(response).to have_http_status(:unprocessable_entity)
|
||||
expect(response).to have_http_status(:unprocessable_content)
|
||||
end
|
||||
end
|
||||
end
|
||||
|
|
|
|||
|
|
@ -47,7 +47,7 @@ RSpec.describe MatchesController, type: :controller do
|
|||
get :index, params: { tournament_id: @tournament.to_param }
|
||||
match = deserialize_response(response).first
|
||||
|
||||
expect(match.keys).to match_array(%i[id position state teams match_scores group stage])
|
||||
expect(match.keys).to match_array(%i[id position state location start_time notes teams match_scores group stage])
|
||||
expect(match[:teams]).to all(include(:id, :name))
|
||||
end
|
||||
|
||||
|
|
@ -72,7 +72,7 @@ RSpec.describe MatchesController, type: :controller do
|
|||
expect(body.map { |match| match[:id] }).to match_array(stage.matches.map(&:id))
|
||||
end
|
||||
end
|
||||
# TODO: add test for upcoming once there is test data for a "valid" group stage
|
||||
# TODO add test for upcoming once there is test data for a "valid" group stage
|
||||
end
|
||||
|
||||
describe 'GET #show' do
|
||||
|
|
@ -86,14 +86,12 @@ RSpec.describe MatchesController, type: :controller do
|
|||
get :show, params: { id: @match.to_param }
|
||||
body = deserialize_response response
|
||||
expect(body[:state]).to eq(@match.state)
|
||||
expect(body[:match_scores].pluck(:id)).to eq(@match.match_scores.map(&:id))
|
||||
expect(body[:match_scores].map { |ms| ms[:id] }).to eq(@match.match_scores.map(&:id))
|
||||
end
|
||||
end
|
||||
|
||||
describe 'POST #create' do
|
||||
let(:group_stage_tournament) do
|
||||
create(:group_stage_tournament, group_count: 1, match_factory: :finished_group_match)
|
||||
end
|
||||
let(:group_stage_tournament) { create(:group_stage_tournament, group_count: 1, match_factory: :finished_group_match) }
|
||||
let(:group) { group_stage_tournament.stages.find_by(level: -1).groups.first }
|
||||
let(:team_ids) { group.teams.first(2).map(&:id) }
|
||||
|
||||
|
|
@ -103,8 +101,10 @@ RSpec.describe MatchesController, type: :controller do
|
|||
end
|
||||
|
||||
it 'creates a decider match at the end of the group' do
|
||||
log_count = group_stage_tournament.transaction_log_entries.count
|
||||
|
||||
expect do
|
||||
post :create, params: { group_id: group.to_param, team_ids: }
|
||||
post :create, params: { group_id: group.to_param, team_ids: team_ids }
|
||||
end.to change { group.matches.reload.count }.by(1)
|
||||
|
||||
expect(response).to have_http_status(:created)
|
||||
|
|
@ -112,6 +112,8 @@ RSpec.describe MatchesController, type: :controller do
|
|||
expect(created_match.decider?).to eq(true)
|
||||
expect(created_match.state).to eq('not_started')
|
||||
expect(created_match.teams.map(&:id)).to match_array(team_ids)
|
||||
expect(group_stage_tournament.transaction_log_entries.count).to eq(log_count + 1)
|
||||
expect(group_stage_tournament.transaction_log_entries.last.action).to eq('decider_match.created')
|
||||
end
|
||||
end
|
||||
|
||||
|
|
@ -122,7 +124,7 @@ RSpec.describe MatchesController, type: :controller do
|
|||
|
||||
it 'rejects the request' do
|
||||
post :create, params: { group_id: group.to_param, team_ids: [group.teams.first.id] }
|
||||
expect(response).to have_http_status(:unprocessable_entity)
|
||||
expect(response).to have_http_status(:unprocessable_content)
|
||||
end
|
||||
end
|
||||
end
|
||||
|
|
@ -160,8 +162,8 @@ RSpec.describe MatchesController, type: :controller do
|
|||
expect(last_match.reload.position).to eq(original_first_position)
|
||||
|
||||
body = deserialize_response(response)
|
||||
expect(body.pluck(:id)).to match_array([first_match.id, last_match.id])
|
||||
expect(body.pluck(:position)).to match_array([original_first_position, original_last_position])
|
||||
expect(body.map { |match| match[:id] }).to match_array([first_match.id, last_match.id])
|
||||
expect(body.map { |match| match[:position] }).to match_array([original_first_position, original_last_position])
|
||||
end
|
||||
|
||||
it 'rejects matches from different groups' do
|
||||
|
|
@ -169,7 +171,7 @@ RSpec.describe MatchesController, type: :controller do
|
|||
|
||||
patch :swap, params: { id: first_match.to_param, other_match_id: foreign_match.to_param }
|
||||
|
||||
expect(response).to have_http_status(:unprocessable_entity)
|
||||
expect(response).to have_http_status(:unprocessable_content)
|
||||
expect(deserialize_response(response)[:error]).to eq('Matches must be different group matches from the same group')
|
||||
end
|
||||
|
||||
|
|
@ -194,6 +196,102 @@ RSpec.describe MatchesController, type: :controller do
|
|||
end
|
||||
end
|
||||
|
||||
describe 'PATCH #metadata' do
|
||||
let(:group_stage_tournament) { create(:group_stage_tournament, group_count: 2, match_factory: :filled_group_match) }
|
||||
|
||||
context 'as owner' do
|
||||
before do
|
||||
apply_authentication_headers_for group_stage_tournament.owner
|
||||
allow(TournamentSyncEnqueue).to receive(:call)
|
||||
allow(TournamentLiveBroadcast).to receive(:call)
|
||||
end
|
||||
|
||||
it 'updates selected matches by explicit ids' do
|
||||
selected_matches = group_stage_tournament.matches.first(2)
|
||||
|
||||
patch :metadata, params: {
|
||||
tournament_id: group_stage_tournament.to_param,
|
||||
match_ids: selected_matches.map(&:id),
|
||||
location: 'Court A',
|
||||
start_time: '17:00',
|
||||
notes: 'Bring player cards'
|
||||
}
|
||||
|
||||
expect(response).to be_successful
|
||||
expect(selected_matches.map { |match| match.reload.location }).to all(eq('Court A'))
|
||||
expect(selected_matches.map { |match| match.reload.start_time }).to all(eq('17:00'))
|
||||
expect(selected_matches.map { |match| match.reload.notes }).to all(eq('Bring player cards'))
|
||||
expect(deserialize_response(response).map { |match| match[:id] }).to eq(selected_matches.map(&:id))
|
||||
expect(TournamentSyncEnqueue).to have_received(:call).with(group_stage_tournament)
|
||||
expect(TournamentLiveBroadcast).to have_received(:call).with(group_stage_tournament.id)
|
||||
end
|
||||
|
||||
it 'updates group stage matches by position filter' do
|
||||
patch :metadata, params: {
|
||||
tournament_id: group_stage_tournament.to_param,
|
||||
group_stage_position: 1,
|
||||
start_time: '18:00'
|
||||
}
|
||||
|
||||
expect(response).to be_successful
|
||||
group_stage_tournament.matches.each do |match|
|
||||
expected_start_time = match.group_match? && match.position == 1 ? '18:00' : nil
|
||||
expect(match.reload.start_time).to eq(expected_start_time)
|
||||
end
|
||||
end
|
||||
|
||||
it 'updates playoff matches by stage level filter' do
|
||||
playoff_tournament = create(:stage_tournament, stage_count: 3, user: group_stage_tournament.owner)
|
||||
target_stage = playoff_tournament.stages.find_by!(level: 3)
|
||||
|
||||
patch :metadata, params: {
|
||||
tournament_id: playoff_tournament.to_param,
|
||||
stage_level: target_stage.level,
|
||||
start_time: '21:00'
|
||||
}
|
||||
|
||||
expect(response).to be_successful
|
||||
playoff_tournament.matches.each do |match|
|
||||
expected_start_time = match.stage == target_stage ? '21:00' : nil
|
||||
expect(match.reload.start_time).to eq(expected_start_time)
|
||||
end
|
||||
end
|
||||
|
||||
it 'requires a selector' do
|
||||
patch :metadata, params: { tournament_id: group_stage_tournament.to_param, start_time: '17:00' }
|
||||
|
||||
expect(response).to have_http_status(:bad_request)
|
||||
expect(deserialize_response(response)[:error]).to eq('Select matches with match_ids, filters, or all=true')
|
||||
end
|
||||
|
||||
it 'rejects matches outside the tournament' do
|
||||
other_match = create(:match)
|
||||
|
||||
patch :metadata, params: {
|
||||
tournament_id: group_stage_tournament.to_param,
|
||||
match_ids: [other_match.id],
|
||||
start_time: '17:00'
|
||||
}
|
||||
|
||||
expect(response).to have_http_status(:not_found)
|
||||
expect(deserialize_response(response)[:error]).to eq("Matches not found in tournament: #{other_match.id}")
|
||||
end
|
||||
|
||||
it 'rejects read only follower tournaments' do
|
||||
group_stage_tournament.update!(read_only_mode: true, sync_auth_token: 'shared-secret')
|
||||
|
||||
patch :metadata, params: {
|
||||
tournament_id: group_stage_tournament.to_param,
|
||||
match_ids: [group_stage_tournament.matches.first.id],
|
||||
start_time: '17:00'
|
||||
}
|
||||
|
||||
expect(response).to have_http_status(:locked)
|
||||
expect(deserialize_response(response)[:error]).to eq('Tournament is in read only follower mode')
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
describe 'POST #update' do
|
||||
context 'on a running playoff match' do
|
||||
let(:valid_update) do
|
||||
|
|
@ -235,6 +333,59 @@ RSpec.describe MatchesController, type: :controller do
|
|||
expect(body[:state]).to eq(valid_update[:state])
|
||||
end
|
||||
|
||||
it 'updates metadata without changing match state' do
|
||||
expect(TournamentLiveBroadcast).to receive(:call).with(@match.tournament.id)
|
||||
|
||||
put :update, params: {
|
||||
id: @match.to_param,
|
||||
location: 'Main Hall',
|
||||
start_time: '17:00',
|
||||
notes: 'Stream setup needed'
|
||||
}
|
||||
|
||||
expect(response).to be_successful
|
||||
@match.reload
|
||||
expect(@match.state).to eq('not_started')
|
||||
expect(@match.location).to eq('Main Hall')
|
||||
expect(@match.start_time).to eq('17:00')
|
||||
expect(@match.notes).to eq('Stream setup needed')
|
||||
expect(deserialize_response(response)).to include(
|
||||
location: 'Main Hall',
|
||||
start_time: '17:00',
|
||||
notes: 'Stream setup needed'
|
||||
)
|
||||
end
|
||||
|
||||
it 'writes a transaction log entry for the state change' do
|
||||
expect(TournamentLiveBroadcast).to receive(:call).with(@match.tournament.id)
|
||||
|
||||
expect do
|
||||
put :update, params: { id: @match.to_param }.merge(valid_update)
|
||||
end.to change { @match.tournament.transaction_log_entries.count }.by(1)
|
||||
|
||||
entry = @match.tournament.transaction_log_entries.last
|
||||
expect(entry.action).to eq('match.state_changed')
|
||||
expect(entry.sequence).to eq(1)
|
||||
expect(entry.message).to include("changed match #{@match.id}")
|
||||
expect(entry.message).to include('running')
|
||||
expect(entry.metadata['new_state']).to eq('in_progress')
|
||||
end
|
||||
|
||||
it 'stops an in-progress match and resets its scores' do
|
||||
@match.update!(state: :in_progress)
|
||||
@match.match_scores.each do |match_score|
|
||||
match_score.update!(points: 7, hidden_points: 5)
|
||||
end
|
||||
|
||||
expect(TournamentLiveBroadcast).to receive(:call).with(@match.tournament.id)
|
||||
put :update, params: { id: @match.to_param, state: 'not_started' }
|
||||
|
||||
expect(response).to be_successful
|
||||
expect(@match.reload.state).to eq('not_started')
|
||||
expect(@match.match_scores.reload.map(&:points)).to all(eq(0))
|
||||
expect(@match.match_scores.reload.map(&:hidden_points)).to all(eq(0))
|
||||
end
|
||||
|
||||
context 'on a running playoff match' do
|
||||
let(:finished) do
|
||||
{
|
||||
|
|
@ -290,7 +441,7 @@ RSpec.describe MatchesController, type: :controller do
|
|||
end
|
||||
|
||||
it 'returns an unprocessable entity response' do
|
||||
expect(response).to have_http_status(:unprocessable_entity)
|
||||
expect(response).to have_http_status(:unprocessable_content)
|
||||
end
|
||||
|
||||
it 'doesn\'t change the matches status' do
|
||||
|
|
@ -323,7 +474,7 @@ RSpec.describe MatchesController, type: :controller do
|
|||
|
||||
it 'returns unprocessable entity' do
|
||||
put :update, params: { id: @running_playoff_match.to_param }.merge(finished)
|
||||
expect(response).to have_http_status(:unprocessable_entity)
|
||||
expect(response).to have_http_status(:unprocessable_content)
|
||||
end
|
||||
end
|
||||
|
||||
|
|
@ -343,7 +494,7 @@ RSpec.describe MatchesController, type: :controller do
|
|||
allow_any_instance_of(Match).to receive(:winner).and_return(@running_playoff_match.match_scores.first.team)
|
||||
|
||||
put :update, params: { id: @running_playoff_match.to_param }.merge(finished)
|
||||
expect(response).to have_http_status(:unprocessable_entity)
|
||||
expect(response).to have_http_status(:unprocessable_content)
|
||||
end
|
||||
end
|
||||
end
|
||||
|
|
@ -377,14 +528,22 @@ RSpec.describe MatchesController, type: :controller do
|
|||
context 'with invalid params' do
|
||||
it 'renders an unprocessable entity response' do
|
||||
put :update, params: { id: @match.to_param }.merge(invalid_update)
|
||||
expect(response).to have_http_status(:unprocessable_entity)
|
||||
expect(response).to have_http_status(:unprocessable_content)
|
||||
end
|
||||
end
|
||||
|
||||
context 'with senseless params' do
|
||||
it 'renders an unprocessable entity response' do
|
||||
put :update, params: { id: @match.to_param }.merge(senseless_update)
|
||||
expect(response).to have_http_status(:unprocessable_entity)
|
||||
expect(response).to have_http_status(:unprocessable_content)
|
||||
end
|
||||
end
|
||||
|
||||
context 'with stop params on a not-started match' do
|
||||
it 'renders an unprocessable entity response' do
|
||||
put :update, params: { id: @match.to_param, state: 'not_started' }
|
||||
expect(response).to have_http_status(:unprocessable_content)
|
||||
expect(deserialize_response(response)[:error]).to eq("Match can't stop in this state")
|
||||
end
|
||||
end
|
||||
end
|
||||
|
|
@ -403,6 +562,34 @@ RSpec.describe MatchesController, type: :controller do
|
|||
end
|
||||
end
|
||||
|
||||
context 'on a running group match' do
|
||||
let(:tournament) { create(:group_stage_tournament, group_count: 1, match_factory: :filled_group_match) }
|
||||
let(:group) { tournament.stages.find_by(level: -1).groups.first }
|
||||
let(:group_match) { group.matches.first }
|
||||
|
||||
before do
|
||||
apply_authentication_headers_for tournament.owner
|
||||
group_match.update!(state: :in_progress)
|
||||
group_match.match_scores.order(:id).each_with_index do |match_score, index|
|
||||
match_score.update!(points: index.zero? ? 9 : 2, hidden_points: index.zero? ? 4 : 1)
|
||||
end
|
||||
UpdateGroupsGroupScoresAndSave.call(group: group)
|
||||
end
|
||||
|
||||
it 'stops the match, clears scores, and recalculates group scores' do
|
||||
put :update, params: { id: group_match.to_param, state: 'not_started' }
|
||||
|
||||
expect(response).to be_successful
|
||||
expect(group_match.reload.state).to eq('not_started')
|
||||
expect(group_match.match_scores.reload.map(&:points)).to all(eq(0))
|
||||
expect(group_match.match_scores.reload.map(&:hidden_points)).to all(eq(0))
|
||||
involved_group_scores = group.group_scores.reload.select { |score| group_match.teams.include?(score.team) }
|
||||
expect(involved_group_scores.map(&:group_points)).to all(eq(0))
|
||||
expect(involved_group_scores.map(&:scored_points)).to all(eq(0))
|
||||
expect(involved_group_scores.map(&:received_points)).to all(eq(0))
|
||||
end
|
||||
end
|
||||
|
||||
context 'on a playoff match that isn\'t ready yet' do
|
||||
let(:invalid_update) do
|
||||
{
|
||||
|
|
@ -418,7 +605,7 @@ RSpec.describe MatchesController, type: :controller do
|
|||
context 'with invalid params' do
|
||||
it 'renders an unprocessable entity response' do
|
||||
put :update, params: { id: @not_ready_playoff_match.to_param }.merge(invalid_update)
|
||||
expect(response).to have_http_status(:unprocessable_entity)
|
||||
expect(response).to have_http_status(:unprocessable_content)
|
||||
end
|
||||
end
|
||||
end
|
||||
|
|
|
|||
|
|
@ -26,7 +26,7 @@ RSpec.describe StagesController, type: :controller do
|
|||
match.save!
|
||||
end
|
||||
|
||||
UpdateGroupsGroupScoresAndSave.call(group:)
|
||||
UpdateGroupsGroupScoresAndSave.call(group: group)
|
||||
end
|
||||
|
||||
stage.reload
|
||||
|
|
@ -97,6 +97,13 @@ RSpec.describe StagesController, type: :controller do
|
|||
tournament = running_group_stage.tournament.reload
|
||||
expect(tournament.team_action_lists).to be_empty
|
||||
end
|
||||
|
||||
it 'writes a transaction log entry for starting playoffs' do
|
||||
entry = running_group_stage.tournament.transaction_log_entries.last
|
||||
expect(entry.action).to eq('playoffs.started')
|
||||
expect(entry.message).to include('started playoffs')
|
||||
expect(entry.metadata['advancing_team_ids']).to match_array(GroupStageService.get_advancing_teams(running_group_stage).map(&:id))
|
||||
end
|
||||
end
|
||||
|
||||
context 'as another user' do
|
||||
|
|
@ -122,7 +129,7 @@ RSpec.describe StagesController, type: :controller do
|
|||
end
|
||||
|
||||
it 'it returns unprocessable entity' do
|
||||
expect(response).to have_http_status(:unprocessable_entity)
|
||||
expect(response).to have_http_status(:unprocessable_content)
|
||||
end
|
||||
|
||||
it 'returns the correct error' do
|
||||
|
|
@ -145,7 +152,7 @@ RSpec.describe StagesController, type: :controller do
|
|||
end
|
||||
|
||||
it 'it returns unprocessable entity' do
|
||||
expect(response).to have_http_status(:unprocessable_entity)
|
||||
expect(response).to have_http_status(:unprocessable_content)
|
||||
end
|
||||
|
||||
it 'returns the correct error' do
|
||||
|
|
@ -164,7 +171,7 @@ RSpec.describe StagesController, type: :controller do
|
|||
end
|
||||
|
||||
it 'it returns unprocessable entity' do
|
||||
expect(response).to have_http_status(:unprocessable_entity)
|
||||
expect(response).to have_http_status(:unprocessable_content)
|
||||
end
|
||||
|
||||
it 'returns the correct error' do
|
||||
|
|
@ -179,20 +186,17 @@ RSpec.describe StagesController, type: :controller do
|
|||
|
||||
before do
|
||||
tournament = group_stage.tournament
|
||||
tournament.update!(playoff_teams_amount: 1, instant_finalists_amount: 1,
|
||||
intermediate_round_participants_amount: 0)
|
||||
tournament.update!(playoff_teams_amount: 1, instant_finalists_amount: 1, intermediate_round_participants_amount: 0)
|
||||
group = create(:group, stage: group_stage, match_count: 0, number: 1)
|
||||
group_stage.groups = [group]
|
||||
team_a = create(:team, tournament:, name: 'A')
|
||||
team_b = create(:team, tournament:, name: 'B')
|
||||
create(:group_match, group:, state: :finished).tap do |match|
|
||||
create(:match_score, match:, team: team_a, points: 2)
|
||||
create(:match_score, match:, team: team_b, points: 2)
|
||||
team_a = create(:team, tournament: tournament, name: 'A')
|
||||
team_b = create(:team, tournament: tournament, name: 'B')
|
||||
create(:group_match, group: group, state: :finished).tap do |match|
|
||||
create(:match_score, match: match, team: team_a, points: 2)
|
||||
create(:match_score, match: match, team: team_b, points: 2)
|
||||
end
|
||||
score_a = create(:group_score, group:, team: team_a, group_points: 7, scored_points: 4,
|
||||
received_points: 1)
|
||||
score_b = create(:group_score, group:, team: team_b, group_points: 7, scored_points: 4,
|
||||
received_points: 1)
|
||||
score_a = create(:group_score, group: group, team: team_a, group_points: 7, scored_points: 4, received_points: 1)
|
||||
score_b = create(:group_score, group: group, team: team_b, group_points: 7, scored_points: 4, received_points: 1)
|
||||
GroupStageService.send(:recalculate_position_of_group_scores!, [score_a, score_b]).each(&:save!)
|
||||
|
||||
apply_authentication_headers_for group_stage.owner
|
||||
|
|
@ -200,7 +204,7 @@ RSpec.describe StagesController, type: :controller do
|
|||
end
|
||||
|
||||
it 'returns unprocessable entity' do
|
||||
expect(response).to have_http_status(:unprocessable_entity)
|
||||
expect(response).to have_http_status(:unprocessable_content)
|
||||
end
|
||||
|
||||
it 'returns a helpful decider error' do
|
||||
|
|
@ -209,6 +213,13 @@ RSpec.describe StagesController, type: :controller do
|
|||
expect(body[:blocking_ties].size).to eq(1)
|
||||
expect(body[:blocking_ties].first[:team_names]).to eq(%w[A B])
|
||||
end
|
||||
|
||||
it 'writes a failed playoff start transaction log entry' do
|
||||
entry = group_stage.tournament.transaction_log_entries.last
|
||||
expect(entry.action).to eq('playoffs.start_failed')
|
||||
expect(entry.message).to include('failed because decider missing')
|
||||
expect(entry.metadata['reason']).to eq('decider_missing')
|
||||
end
|
||||
end
|
||||
|
||||
context 'when tied advancing teams still need a decider to fix playoff seeding' do
|
||||
|
|
@ -218,20 +229,17 @@ RSpec.describe StagesController, type: :controller do
|
|||
|
||||
before do
|
||||
tournament = group_stage.tournament
|
||||
tournament.update!(playoff_teams_amount: 2, instant_finalists_amount: 2,
|
||||
intermediate_round_participants_amount: 0)
|
||||
tournament.update!(playoff_teams_amount: 2, instant_finalists_amount: 2, intermediate_round_participants_amount: 0)
|
||||
group = create(:group, stage: group_stage, match_count: 0, number: 1)
|
||||
group_stage.groups = [group]
|
||||
team_a = create(:team, tournament:, name: 'A')
|
||||
team_b = create(:team, tournament:, name: 'B')
|
||||
create(:group_match, group:, state: :finished).tap do |match|
|
||||
create(:match_score, match:, team: team_a, points: 2)
|
||||
create(:match_score, match:, team: team_b, points: 2)
|
||||
team_a = create(:team, tournament: tournament, name: 'A')
|
||||
team_b = create(:team, tournament: tournament, name: 'B')
|
||||
create(:group_match, group: group, state: :finished).tap do |match|
|
||||
create(:match_score, match: match, team: team_a, points: 2)
|
||||
create(:match_score, match: match, team: team_b, points: 2)
|
||||
end
|
||||
score_a = create(:group_score, group:, team: team_a, group_points: 7, scored_points: 4,
|
||||
received_points: 1)
|
||||
score_b = create(:group_score, group:, team: team_b, group_points: 7, scored_points: 4,
|
||||
received_points: 1)
|
||||
score_a = create(:group_score, group: group, team: team_a, group_points: 7, scored_points: 4, received_points: 1)
|
||||
score_b = create(:group_score, group: group, team: team_b, group_points: 7, scored_points: 4, received_points: 1)
|
||||
GroupStageService.send(:recalculate_position_of_group_scores!, [score_a, score_b]).each(&:save!)
|
||||
|
||||
apply_authentication_headers_for group_stage.owner
|
||||
|
|
@ -239,7 +247,7 @@ RSpec.describe StagesController, type: :controller do
|
|||
end
|
||||
|
||||
it 'returns unprocessable entity' do
|
||||
expect(response).to have_http_status(:unprocessable_entity)
|
||||
expect(response).to have_http_status(:unprocessable_content)
|
||||
end
|
||||
|
||||
it 'returns a helpful decider error' do
|
||||
|
|
|
|||
|
|
@ -47,7 +47,7 @@ RSpec.describe TeamActionListsController, type: :controller do
|
|||
it 'returns unprocessable entity for unsupported source' do
|
||||
post :create, params: create_params.merge(source: 'unknown_source')
|
||||
|
||||
expect(response).to have_http_status(:unprocessable_entity)
|
||||
expect(response).to have_http_status(:unprocessable_content)
|
||||
expect(deserialize_response(response)[:error]).to eq('Unsupported team action list source: unknown_source')
|
||||
end
|
||||
|
||||
|
|
@ -57,7 +57,7 @@ RSpec.describe TeamActionListsController, type: :controller do
|
|||
|
||||
post :create, params: create_params.merge(tournament_id: empty_tournament.to_param)
|
||||
|
||||
expect(response).to have_http_status(:unprocessable_entity)
|
||||
expect(response).to have_http_status(:unprocessable_content)
|
||||
expect(deserialize_response(response)[:error]).to eq('Source all_tournament_teams does not currently contain any teams')
|
||||
end
|
||||
|
||||
|
|
@ -71,7 +71,7 @@ RSpec.describe TeamActionListsController, type: :controller do
|
|||
|
||||
post :create, params: create_params
|
||||
|
||||
expect(response).to have_http_status(:unprocessable_entity)
|
||||
expect(response).to have_http_status(:unprocessable_content)
|
||||
end
|
||||
end
|
||||
|
||||
|
|
|
|||
|
|
@ -19,6 +19,17 @@ RSpec.describe TeamsController, type: :controller do
|
|||
body = deserialize_response response
|
||||
expect(body[:name]).to eq(@team.name)
|
||||
end
|
||||
|
||||
it 'returns player account summaries when linked' do
|
||||
player = create(:user, username: 'linked-player')
|
||||
@team.players << player
|
||||
|
||||
get :show, params: { id: @team.to_param }
|
||||
|
||||
body = deserialize_response response
|
||||
expect(body[:players].first[:id]).to eq(player.id)
|
||||
expect(body[:players].first[:name]).to eq('linked-player')
|
||||
end
|
||||
end
|
||||
|
||||
describe 'PUT #update' do
|
||||
|
|
@ -46,6 +57,49 @@ RSpec.describe TeamsController, type: :controller do
|
|||
body = deserialize_response response
|
||||
expect(body[:name]).to eq(valid_update[:name])
|
||||
end
|
||||
|
||||
it 'associates an existing player account by email' do
|
||||
player = create(:user, username: 'player-account', email: 'player@example.com')
|
||||
|
||||
put :update, params: { id: @team.to_param, player_email: player.email }
|
||||
|
||||
expect(response).to be_successful
|
||||
expect(@team.reload.players).to contain_exactly(player)
|
||||
expect(deserialize_response(response)[:players].first[:id]).to eq(player.id)
|
||||
end
|
||||
|
||||
it 'creates a player account by email when none exists' do
|
||||
expect do
|
||||
put :update, params: { id: @team.to_param, player_email: 'new-player@example.com' }
|
||||
end.to change(User, :count).by(1)
|
||||
|
||||
expect(response).to be_successful
|
||||
expect(@team.reload.players.first.email).to eq('new-player@example.com')
|
||||
expect(@team.players.first.username).to eq('new-player')
|
||||
end
|
||||
|
||||
it 'associates multiple player accounts by email' do
|
||||
first_player = create(:user, email: 'first-player@example.com')
|
||||
|
||||
put :update, params: {
|
||||
id: @team.to_param,
|
||||
player_emails: [first_player.email, 'second-player@example.com']
|
||||
}
|
||||
|
||||
expect(response).to be_successful
|
||||
expect(@team.reload.players.map(&:email)).to match_array(
|
||||
['first-player@example.com', 'second-player@example.com']
|
||||
)
|
||||
end
|
||||
|
||||
it 'clears player associations with blank player_id' do
|
||||
@team.players << create(:user)
|
||||
|
||||
put :update, params: { id: @team.to_param, player_id: '' }
|
||||
|
||||
expect(response).to be_successful
|
||||
expect(@team.reload.players).to be_empty
|
||||
end
|
||||
end
|
||||
|
||||
context 'with valid params as another user' do
|
||||
|
|
@ -58,5 +112,29 @@ RSpec.describe TeamsController, type: :controller do
|
|||
expect(response).to have_http_status(:forbidden)
|
||||
end
|
||||
end
|
||||
|
||||
context 'with valid params as linked player' do
|
||||
before(:each) do
|
||||
@player = create(:user)
|
||||
@team.players << @player
|
||||
apply_authentication_headers_for @player
|
||||
end
|
||||
|
||||
it 'updates the requested team name' do
|
||||
put :update, params: { id: @team.to_param }.merge(valid_update)
|
||||
|
||||
expect(response).to be_successful
|
||||
expect(@team.reload.name).to eq(valid_update[:name])
|
||||
end
|
||||
|
||||
it 'cannot reassign the linked player' do
|
||||
new_player = create(:user, email: 'other-player@example.com')
|
||||
|
||||
put :update, params: { id: @team.to_param, player_email: new_player.email }
|
||||
|
||||
expect(response).to have_http_status(:forbidden)
|
||||
expect(@team.reload.players).to contain_exactly(@player)
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
|
|
|
|||
|
|
@ -0,0 +1,50 @@
|
|||
# frozen_string_literal: true
|
||||
|
||||
require 'rails_helper'
|
||||
|
||||
RSpec.describe TournamentTransactionLogEntriesController, type: :controller do
|
||||
let(:tournament) { create(:tournament) }
|
||||
let(:owner) { tournament.owner }
|
||||
|
||||
before do
|
||||
TournamentTransactionLogger.record!(
|
||||
tournament: tournament,
|
||||
user: owner,
|
||||
action: 'test.entry',
|
||||
message: 'owner changed match 1',
|
||||
metadata: { match_id: 1 }
|
||||
)
|
||||
end
|
||||
|
||||
describe 'GET #index' do
|
||||
context 'as owner' do
|
||||
before do
|
||||
apply_authentication_headers_for owner
|
||||
end
|
||||
|
||||
it 'returns ordered transaction log entries' do
|
||||
get :index, params: { id: tournament.to_param }
|
||||
|
||||
expect(response).to be_successful
|
||||
body = deserialize_response(response)
|
||||
expect(body.size).to eq(1)
|
||||
expect(body.first[:sequence]).to eq(1)
|
||||
expect(body.first[:action]).to eq('test.entry')
|
||||
expect(body.first[:message]).to eq('owner changed match 1')
|
||||
expect(body.first[:metadata]).to eq(match_id: 1)
|
||||
end
|
||||
end
|
||||
|
||||
context 'as another user' do
|
||||
before do
|
||||
apply_authentication_headers_for create(:user)
|
||||
end
|
||||
|
||||
it 'returns forbidden' do
|
||||
get :index, params: { id: tournament.to_param }
|
||||
|
||||
expect(response).to have_http_status(:forbidden)
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
|
|
@ -24,7 +24,7 @@ RSpec.describe TournamentsController, type: :controller do
|
|||
get :index
|
||||
tournaments = deserialize_response response
|
||||
public_tournaments = tournaments.select { |t| t[:public] }
|
||||
expect(public_tournaments.pluck(:id)).to match_array(Tournament.where(public: true).pluck(:id))
|
||||
expect(public_tournaments.map { |t| t[:id] }).to match_array(Tournament.where(public: true).map { |t| t[:id] })
|
||||
end
|
||||
end
|
||||
|
||||
|
|
@ -35,15 +35,15 @@ RSpec.describe TournamentsController, type: :controller do
|
|||
|
||||
it 'returns all private tournaments' do
|
||||
apply_authentication_headers_for @another_user
|
||||
get(:index, params:)
|
||||
get :index, params: params
|
||||
tournaments = deserialize_response response
|
||||
private_tournaments = Tournament.where(owner: @another_user, public: false).pluck(:id)
|
||||
returned_private_tournaments = tournaments.filter { |t| !t[:public] }.pluck(:id)
|
||||
private_tournaments = Tournament.where(owner: @another_user, public: false).map { |t| t[:id] }
|
||||
returned_private_tournaments = tournaments.filter { |t| !t[:public] }.map { |t| t[:id] }
|
||||
expect(returned_private_tournaments).to match_array(private_tournaments)
|
||||
end
|
||||
|
||||
it 'returns no private tournaments for unauthenticated users' do
|
||||
get(:index, params:)
|
||||
get :index, params: params
|
||||
tournaments = deserialize_response response
|
||||
private_tournaments = tournaments.reject { |t| t[:public] }
|
||||
expect(private_tournaments.size).to eq(0)
|
||||
|
|
@ -51,14 +51,14 @@ RSpec.describe TournamentsController, type: :controller do
|
|||
|
||||
it 'returns no private tournaments owned by another user' do
|
||||
apply_authentication_headers_for @user
|
||||
get(:index, params:)
|
||||
get :index, params: params
|
||||
tournaments = deserialize_response response
|
||||
expect(tournaments.pluck(:id)).not_to include(@private_tournament.id)
|
||||
expect(tournaments.map { |t| t[:id] }).not_to include(@private_tournament.id)
|
||||
end
|
||||
|
||||
it 'returns no public tournaments' do
|
||||
apply_authentication_headers_for @another_user
|
||||
get(:index, params:)
|
||||
get :index, params: params
|
||||
tournaments = deserialize_response response
|
||||
expect(tournaments.count { |t| t[:public] }).to eq(0)
|
||||
end
|
||||
|
|
@ -70,15 +70,15 @@ RSpec.describe TournamentsController, type: :controller do
|
|||
end
|
||||
|
||||
it 'returns all public tournaments' do
|
||||
get(:index, params:)
|
||||
get :index, params: params
|
||||
tournaments = deserialize_response response
|
||||
public_tournaments = tournaments.select { |t| t[:public] }
|
||||
expect(public_tournaments.pluck(:id)).to match_array(Tournament.where(public: true).pluck(:id))
|
||||
expect(public_tournaments.map { |t| t[:id] }).to match_array(Tournament.where(public: true).map { |t| t[:id] })
|
||||
end
|
||||
|
||||
it 'returns no private tournaments' do
|
||||
apply_authentication_headers_for @another_user
|
||||
get(:index, params:)
|
||||
get :index, params: params
|
||||
tournaments = deserialize_response response
|
||||
expect(tournaments.count { |t| !t[:public] }).to eq(0)
|
||||
end
|
||||
|
|
@ -105,7 +105,8 @@ RSpec.describe TournamentsController, type: :controller do
|
|||
expected_keys = %i[
|
||||
id name code public description playoff_teams_amount
|
||||
instant_finalists_amount intermediate_round_participants_amount
|
||||
timestamp timer_mode owner_username stages teams team_action_lists
|
||||
location starts_at ends_at website_url
|
||||
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[:name]).to eq(@tournament.name)
|
||||
|
|
@ -150,9 +151,11 @@ RSpec.describe TournamentsController, type: :controller do
|
|||
json = deserialize_response(response)
|
||||
expect(json[:read_only_mode]).to eq(true)
|
||||
expect(json[:sync_target_url]).to eq('https://remote.example.com/tournaments/1/sync_state')
|
||||
expect(json[:sync_auth_configured]).to eq(true)
|
||||
expect(json[:sync_last_pushed_at]).to eq(pushed_at.iso8601)
|
||||
expect(json[:sync_last_push_error]).to eq('push failed')
|
||||
expect(json).not_to have_key(:sync_auth_token)
|
||||
expect(json).not_to have_key(:sync_auth_token_digest)
|
||||
expect(json).to eq(TournamentSerializer.new(@tournament, scope: @tournament.owner).as_json)
|
||||
end
|
||||
|
||||
|
|
@ -224,13 +227,20 @@ RSpec.describe TournamentsController, type: :controller do
|
|||
describe 'GET #timer' do
|
||||
it 'returns timer state in ISO 8601 format' do
|
||||
timestamp = Time.utc(2026, 4, 13, 18, 0, 0)
|
||||
@tournament.update!(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 }
|
||||
|
||||
expect(response).to be_successful
|
||||
expect(deserialize_response(response)[:timestamp]).to eq(timestamp.iso8601)
|
||||
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
|
||||
|
||||
|
|
@ -242,43 +252,143 @@ RSpec.describe TournamentsController, type: :controller do
|
|||
it 'stores timezone-aware timestamp values without shifting them' do
|
||||
timestamp = '2030-04-13T18:00:00+02:00'
|
||||
|
||||
patch :set_timer, params: { id: @tournament.to_param, timestamp: }
|
||||
patch :set_timer, params: {
|
||||
id: @tournament.to_param,
|
||||
timestamp: timestamp,
|
||||
timer_reason: 'tournament_start'
|
||||
}
|
||||
|
||||
expect(response).to be_successful
|
||||
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)[:timer_mode]).to eq('countdown')
|
||||
expect(deserialize_response(response)[:timer_reason]).to eq('tournament_start')
|
||||
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' }
|
||||
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(@tournament.reload.timer_mode).to eq('countup')
|
||||
expect(@tournament.timestamp).to be_between(before_request - 1.second, 1.second.from_now)
|
||||
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(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
|
||||
|
||||
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 }
|
||||
|
||||
expect(response).to be_successful
|
||||
expect(@tournament.reload.timestamp).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)[: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
|
||||
|
||||
it 'updates timer reason without changing active timer clock' do
|
||||
original_timestamp = Time.utc(2030, 4, 13, 18, 0, 0)
|
||||
@tournament.update!(
|
||||
timestamp: original_timestamp,
|
||||
timer_mode: 'countdown',
|
||||
timer_reason: 'tournament_start'
|
||||
)
|
||||
|
||||
patch :set_timer, params: {
|
||||
id: @tournament.to_param,
|
||||
timer_reason: 'custom',
|
||||
timer_reason_text: 'Schedule pause'
|
||||
}
|
||||
|
||||
expect(response).to be_successful
|
||||
expect(@tournament.reload.timestamp).to eq(original_timestamp)
|
||||
expect(@tournament.timer_mode).to eq('countdown')
|
||||
expect(@tournament.timer_reason).to eq('custom')
|
||||
expect(@tournament.timer_reason_text).to eq('Schedule pause')
|
||||
expect(deserialize_response(response)[:timestamp]).to eq(original_timestamp.iso8601)
|
||||
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('Schedule pause')
|
||||
|
||||
patch :set_timer, params: {
|
||||
id: @tournament.to_param,
|
||||
timer_reason: ''
|
||||
}
|
||||
|
||||
expect(response).to be_successful
|
||||
expect(@tournament.reload.timestamp).to eq(original_timestamp)
|
||||
expect(@tournament.timer_mode).to eq('countdown')
|
||||
expect(@tournament.timer_reason).to be_nil
|
||||
expect(@tournament.timer_reason_text).to be_nil
|
||||
expect(deserialize_response(response)[:timestamp]).to eq(original_timestamp.iso8601)
|
||||
expect(deserialize_response(response)[:timer_mode]).to eq('countdown')
|
||||
expect(deserialize_response(response)[:timer_reason]).to be_nil
|
||||
expect(deserialize_response(response)[:timer_reason_text]).to be_nil
|
||||
end
|
||||
|
||||
it 'rejects timer reason updates when no timer is active' do
|
||||
patch :set_timer, params: { id: @tournament.to_param, timer_reason: 'match_end' }
|
||||
|
||||
expect(response).to have_http_status(:unprocessable_content)
|
||||
expect(deserialize_response(response)[:error]).to eq('timer_reason requires active timer')
|
||||
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(response).to have_http_status(:unprocessable_content)
|
||||
expect(deserialize_response(response)[:error]).to eq('timestamp_seconds is only allowed for countdown mode')
|
||||
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_content)
|
||||
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_content)
|
||||
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_content)
|
||||
expect(deserialize_response(response)[:error]).to eq(
|
||||
'timer_reason_text is only allowed when timer_reason is custom'
|
||||
)
|
||||
end
|
||||
end
|
||||
|
||||
describe 'POST #create' do
|
||||
|
|
@ -329,8 +439,12 @@ RSpec.describe TournamentsController, type: :controller do
|
|||
expect(response).to have_http_status(:created)
|
||||
tournament = Tournament.find(deserialize_response(response)[:id])
|
||||
expect(tournament.read_only_mode?).to eq(true)
|
||||
expect(tournament.sync_auth_token).to be_nil
|
||||
expect(tournament.sync_auth_token_digest).to be_present
|
||||
expect(tournament.teams).to be_empty
|
||||
expect(tournament.stages).to be_empty
|
||||
expect(deserialize_response(response)).not_to have_key(:sync_auth_token)
|
||||
expect(deserialize_response(response)).not_to have_key(:sync_auth_token_digest)
|
||||
end
|
||||
end
|
||||
|
||||
|
|
@ -341,6 +455,25 @@ RSpec.describe TournamentsController, type: :controller do
|
|||
end.to change(Tournament, :count).by(1)
|
||||
end
|
||||
|
||||
it 'stores tournament metadata' do
|
||||
starts_at = '2030-06-01T10:00:00+02:00'
|
||||
ends_at = '2030-06-01T18:00:00+02:00'
|
||||
|
||||
post :create, params: create_playoff_tournament_data.merge(
|
||||
location: 'Stadtpark Halle',
|
||||
starts_at: starts_at,
|
||||
ends_at: ends_at,
|
||||
website_url: 'https://example.com/tournaments/summer-cup'
|
||||
)
|
||||
|
||||
expect(response).to have_http_status(:created)
|
||||
tournament = Tournament.find(deserialize_response(response)[:id])
|
||||
expect(tournament.location).to eq('Stadtpark Halle')
|
||||
expect(tournament.starts_at.iso8601).to eq(Time.iso8601(starts_at).utc.iso8601)
|
||||
expect(tournament.ends_at.iso8601).to eq(Time.iso8601(ends_at).utc.iso8601)
|
||||
expect(tournament.website_url).to eq('https://example.com/tournaments/summer-cup')
|
||||
end
|
||||
|
||||
it 'associates the new tournament with the authenticated user' do
|
||||
expect do
|
||||
post :create, params: create_playoff_tournament_data
|
||||
|
|
@ -406,7 +539,7 @@ RSpec.describe TournamentsController, type: :controller do
|
|||
context 'playoff_teams_amount unacceptable' do
|
||||
shared_examples_for 'wrong playoff_teams_amount' do
|
||||
it 'fails' do
|
||||
expect(response).to have_http_status(:unprocessable_entity)
|
||||
expect(response).to have_http_status(:unprocessable_content)
|
||||
end
|
||||
it 'returns the correct error message' do
|
||||
expect(deserialize_response(response)[:playoff_teams_amount].first)
|
||||
|
|
@ -462,7 +595,7 @@ RSpec.describe TournamentsController, type: :controller do
|
|||
data = create_group_tournament_data
|
||||
data[:teams].pop
|
||||
post :create, params: data
|
||||
expect(response).to have_http_status(:unprocessable_entity)
|
||||
expect(response).to have_http_status(:unprocessable_content)
|
||||
end
|
||||
end
|
||||
|
||||
|
|
@ -475,12 +608,35 @@ RSpec.describe TournamentsController, type: :controller do
|
|||
post :create, params: data
|
||||
end.to change(Team, :count).by(data[:teams].count)
|
||||
end
|
||||
|
||||
it 'creates player accounts from team email values' do
|
||||
data = create_playoff_tournament_data
|
||||
data.delete :teams
|
||||
data[:teams] = [
|
||||
{
|
||||
name: 'Alpha',
|
||||
player_emails: ['alpha-player@example.com', 'alpha-partner@example.com']
|
||||
},
|
||||
{ name: 'Beta' },
|
||||
{ name: 'Gamma' },
|
||||
{ name: 'Delta' }
|
||||
]
|
||||
|
||||
expect do
|
||||
post :create, params: data
|
||||
end.to change(User, :count).by(2)
|
||||
|
||||
tournament = Tournament.find(deserialize_response(response)[:id])
|
||||
expect(tournament.teams.find_by(name: 'Alpha').players.map(&:email))
|
||||
.to match_array(['alpha-player@example.com', 'alpha-partner@example.com'])
|
||||
expect(tournament.teams.find_by(name: 'Beta').players).to be_empty
|
||||
end
|
||||
end
|
||||
|
||||
context 'with invalid parameters' do
|
||||
it 'renders an unprocessable entity response' do
|
||||
put :create, params: { teams: [1, 2, 3] }
|
||||
expect(response).to have_http_status(:unprocessable_entity)
|
||||
expect(response).to have_http_status(:unprocessable_content)
|
||||
end
|
||||
end
|
||||
|
||||
|
|
@ -489,7 +645,7 @@ RSpec.describe TournamentsController, type: :controller do
|
|||
data = create_group_tournament_data
|
||||
data[:teams] = [{ group: 1 }, { group: 1 }, { group: 2 }, { group: 2 }]
|
||||
post :create, params: data
|
||||
expect(response).to have_http_status(:unprocessable_entity)
|
||||
expect(response).to have_http_status(:unprocessable_content)
|
||||
end
|
||||
end
|
||||
end
|
||||
|
|
@ -521,6 +677,43 @@ RSpec.describe TournamentsController, type: :controller do
|
|||
expect(@tournament.name).to eq(valid_update[:name])
|
||||
end
|
||||
|
||||
it 'updates tournament metadata' do
|
||||
starts_at = '2030-06-01T10:00:00+02:00'
|
||||
ends_at = '2030-06-01T18:00:00+02:00'
|
||||
|
||||
put :update, params: {
|
||||
id: @tournament.to_param,
|
||||
location: 'Arena Nord',
|
||||
starts_at: starts_at,
|
||||
ends_at: ends_at,
|
||||
website_url: 'https://example.com/events/arena-nord'
|
||||
}
|
||||
|
||||
expect(response).to have_http_status(:ok)
|
||||
@tournament.reload
|
||||
json = deserialize_response(response)
|
||||
expect(@tournament.location).to eq('Arena Nord')
|
||||
expect(@tournament.starts_at.iso8601).to eq(Time.iso8601(starts_at).utc.iso8601)
|
||||
expect(@tournament.ends_at.iso8601).to eq(Time.iso8601(ends_at).utc.iso8601)
|
||||
expect(@tournament.website_url).to eq('https://example.com/events/arena-nord')
|
||||
expect(json[:starts_at]).to eq(@tournament.starts_at.iso8601)
|
||||
expect(json[:ends_at]).to eq(@tournament.ends_at.iso8601)
|
||||
end
|
||||
|
||||
it 'rejects invalid tournament metadata' do
|
||||
put :update, params: {
|
||||
id: @tournament.to_param,
|
||||
starts_at: '2030-06-01T18:00:00+02:00',
|
||||
ends_at: '2030-06-01T10:00:00+02:00',
|
||||
website_url: 'notaurl'
|
||||
}
|
||||
|
||||
expect(response).to have_http_status(:unprocessable_content)
|
||||
json = deserialize_response(response)
|
||||
expect(json[:ends_at]).to include('must be after starts_at')
|
||||
expect(json[:website_url]).to include('is invalid')
|
||||
end
|
||||
|
||||
it 'renders a JSON response with the tournament' do
|
||||
put :update, params: { id: @tournament.to_param }.merge(valid_update)
|
||||
expect(response).to have_http_status(:ok)
|
||||
|
|
@ -542,6 +735,9 @@ RSpec.describe TournamentsController, type: :controller do
|
|||
expect(@tournament.read_only_mode?).to eq(false)
|
||||
expect(@tournament.sync_target_url).to include('/sync_state')
|
||||
expect(@tournament.sync_auth_token).to eq('new-token')
|
||||
expect(@tournament.sync_auth_token_digest).to be_nil
|
||||
expect(deserialize_response(response)).not_to have_key(:sync_auth_token)
|
||||
expect(deserialize_response(response)).not_to have_key(:sync_auth_token_digest)
|
||||
end
|
||||
|
||||
it 'allows follower takeover when only read_only_mode is disabled' do
|
||||
|
|
@ -558,6 +754,7 @@ RSpec.describe TournamentsController, type: :controller do
|
|||
expect(@tournament.read_only_mode?).to eq(false)
|
||||
expect(@tournament.sync_target_url).to be_blank
|
||||
expect(@tournament.sync_auth_token).to be_blank
|
||||
expect(@tournament.sync_auth_token_digest).to be_blank
|
||||
end
|
||||
|
||||
it 'blocks normal updates while tournament is read only' do
|
||||
|
|
@ -577,18 +774,18 @@ RSpec.describe TournamentsController, type: :controller do
|
|||
|
||||
it 'fails when only instant_finalists_amount is changed' do
|
||||
put :update, params: { id: @filled_tournament.to_param }.merge(instant_finalists_amount: 29)
|
||||
expect(response).to have_http_status(:unprocessable_entity)
|
||||
expect(response).to have_http_status(:unprocessable_content)
|
||||
end
|
||||
|
||||
it 'fails when only intermediate_round_participants_amount is changed' do
|
||||
put :update, params: { id: @filled_tournament.to_param }.merge(intermediate_round_participants_amount: 29)
|
||||
expect(response).to have_http_status(:unprocessable_entity)
|
||||
expect(response).to have_http_status(:unprocessable_content)
|
||||
end
|
||||
|
||||
it 'fails when parameters don\'t match' do
|
||||
put :update, params: { id: @filled_tournament.to_param }.merge(intermediate_round_participants_amount: 29,
|
||||
instant_finalists_amount: 32)
|
||||
expect(response).to have_http_status(:unprocessable_entity)
|
||||
expect(response).to have_http_status(:unprocessable_content)
|
||||
end
|
||||
|
||||
it 'succeeds when all three are changed correctly' do
|
||||
|
|
@ -607,7 +804,7 @@ RSpec.describe TournamentsController, type: :controller do
|
|||
|
||||
it 'returns unprocessable entity' do
|
||||
put :update, params: { id: @filled_tournament.to_param }.merge(playoff_teams_amount: 8)
|
||||
expect(response).to have_http_status(:unprocessable_entity)
|
||||
expect(response).to have_http_status(:unprocessable_content)
|
||||
end
|
||||
|
||||
it 'doesn\'t change playoff_teams_amount' do
|
||||
|
|
@ -660,7 +857,7 @@ RSpec.describe TournamentsController, type: :controller do
|
|||
|
||||
it 'fails when playoff_teams_amount is higher than the amount of teams participating' do
|
||||
put :update, params: { id: @filled_tournament.to_param }.merge(playoff_teams_amount: 783)
|
||||
expect(response).to have_http_status(:unprocessable_entity)
|
||||
expect(response).to have_http_status(:unprocessable_content)
|
||||
end
|
||||
end
|
||||
end
|
||||
|
|
@ -724,6 +921,119 @@ RSpec.describe TournamentsController, type: :controller do
|
|||
end
|
||||
end
|
||||
|
||||
describe 'DELETE #destroy_playoffs' do
|
||||
context 'without authentication headers' do
|
||||
it 'renders an unauthorized error response' do
|
||||
tournament = create_group_stage_with_unplayed_playoffs
|
||||
|
||||
delete :destroy_playoffs, params: { id: tournament.to_param }
|
||||
|
||||
expect(response).to have_http_status(:unauthorized)
|
||||
end
|
||||
end
|
||||
|
||||
context 'as owner' do
|
||||
before do
|
||||
apply_authentication_headers_for tournament.owner
|
||||
allow(TournamentLiveBroadcast).to receive(:call)
|
||||
end
|
||||
|
||||
let(:tournament) { create_group_stage_with_unplayed_playoffs }
|
||||
|
||||
it 'deletes unplayed playoff stages and reopens the group stage' do
|
||||
expect do
|
||||
delete :destroy_playoffs, params: { id: tournament.to_param }
|
||||
end.to change { tournament.reload.stages.where('level >= 0').count }.from(1).to(0)
|
||||
|
||||
expect(response).to have_http_status(:ok)
|
||||
expect(tournament.group_stage.reload).to be_in_progress
|
||||
expect(deserialize_response(response).fetch(:stages).map { |stage| stage.fetch(:level) }).to eq([-1])
|
||||
end
|
||||
|
||||
it 'pushes sync and live updates after deletion' do
|
||||
expect(TournamentSyncEnqueue).to receive(:call).with(tournament)
|
||||
expect(TournamentLiveBroadcast).to receive(:call).with(tournament.id)
|
||||
|
||||
delete :destroy_playoffs, params: { id: tournament.to_param }
|
||||
|
||||
expect(response).to have_http_status(:ok)
|
||||
end
|
||||
|
||||
it 'rejects deletion after a playoff match has started' do
|
||||
playoff_match = tournament.stages.find { |stage| stage.level >= 0 }.matches.first
|
||||
playoff_match.update!(state: :in_progress)
|
||||
|
||||
expect do
|
||||
delete :destroy_playoffs, params: { id: tournament.to_param }
|
||||
end.not_to(change { tournament.reload.stages.where('level >= 0').count })
|
||||
|
||||
expect(response).to have_http_status(:unprocessable_content)
|
||||
expect(deserialize_response(response)[:error]).to eq(
|
||||
'Playoffs cannot be deleted after matches have started or scores changed'
|
||||
)
|
||||
expect(tournament.group_stage.reload).to be_finished
|
||||
end
|
||||
|
||||
it 'rejects deletion after a playoff score has changed' do
|
||||
playoff_match = tournament.stages.find { |stage| stage.level >= 0 }.matches.first
|
||||
playoff_match.match_scores.first.update!(points: 1)
|
||||
|
||||
delete :destroy_playoffs, params: { id: tournament.to_param }
|
||||
|
||||
expect(response).to have_http_status(:unprocessable_content)
|
||||
expect(deserialize_response(response)[:error]).to eq(
|
||||
'Playoffs cannot be deleted after matches have started or scores changed'
|
||||
)
|
||||
expect(tournament.reload.stages.where('level >= 0').count).to eq(1)
|
||||
end
|
||||
|
||||
it 'rejects deletion when no group stage exists' do
|
||||
playoff_only = create(:stage_tournament, stage_count: 1)
|
||||
apply_authentication_headers_for playoff_only.owner
|
||||
|
||||
delete :destroy_playoffs, params: { id: playoff_only.to_param }
|
||||
|
||||
expect(response).to have_http_status(:unprocessable_content)
|
||||
expect(deserialize_response(response)[:error]).to eq('Only group-stage playoffs can be deleted')
|
||||
end
|
||||
|
||||
it 'blocks deletion while tournament is read only' do
|
||||
tournament.update!(read_only_mode: true, sync_auth_token: 'shared-secret')
|
||||
|
||||
delete :destroy_playoffs, params: { id: tournament.to_param }
|
||||
|
||||
expect(response).to have_http_status(:locked)
|
||||
end
|
||||
end
|
||||
|
||||
context 'as another user' do
|
||||
it 'renders a forbidden error response' do
|
||||
tournament = create_group_stage_with_unplayed_playoffs
|
||||
apply_authentication_headers_for create(:user)
|
||||
|
||||
delete :destroy_playoffs, params: { id: tournament.to_param }
|
||||
|
||||
expect(response).to have_http_status(:forbidden)
|
||||
end
|
||||
end
|
||||
|
||||
def create_group_stage_with_unplayed_playoffs
|
||||
tournament = create(
|
||||
:group_stage_tournament,
|
||||
stage_count: 0,
|
||||
group_count: 2,
|
||||
teams_count: 4,
|
||||
playoff_teams_amount: 2,
|
||||
instant_finalists_amount: 2,
|
||||
intermediate_round_participants_amount: 0
|
||||
)
|
||||
tournament.group_stage.update!(state: :finished)
|
||||
result = AddPlayoffsToTournamentAndSave.call(tournament: tournament, teams: tournament.teams.take(2))
|
||||
expect(result).to be_a_success
|
||||
tournament.reload
|
||||
end
|
||||
end
|
||||
|
||||
describe 'PATCH #sync_state' do
|
||||
before do
|
||||
@tournament.update!(read_only_mode: true, sync_auth_token: 'shared-secret')
|
||||
|
|
@ -740,6 +1050,10 @@ RSpec.describe TournamentsController, type: :controller do
|
|||
code: 'abc123',
|
||||
description: 'Synced description',
|
||||
public: true,
|
||||
location: 'Synced Arena',
|
||||
starts_at: '2030-06-01T10:00:00+02:00',
|
||||
ends_at: '2030-06-01T18:00:00+02:00',
|
||||
website_url: 'https://example.com/synced',
|
||||
playoff_teams_amount: 4,
|
||||
instant_finalists_amount: 4,
|
||||
intermediate_round_participants_amount: 0,
|
||||
|
|
@ -757,7 +1071,13 @@ RSpec.describe TournamentsController, type: :controller do
|
|||
@tournament.reload
|
||||
expect(@tournament.sync_source_tournament_id).to eq(123)
|
||||
expect(@tournament.name).to eq('Synced Tournament')
|
||||
expect(@tournament.location).to eq('Synced Arena')
|
||||
expect(@tournament.starts_at.iso8601).to eq(Time.iso8601('2030-06-01T10:00:00+02:00').utc.iso8601)
|
||||
expect(@tournament.ends_at.iso8601).to eq(Time.iso8601('2030-06-01T18:00:00+02:00').utc.iso8601)
|
||||
expect(@tournament.website_url).to eq('https://example.com/synced')
|
||||
expect(@tournament.teams.pluck(:name)).to eq(['Alpha'])
|
||||
expect(deserialize_response(response)).not_to have_key(:sync_auth_token)
|
||||
expect(deserialize_response(response)).not_to have_key(:sync_auth_token_digest)
|
||||
end
|
||||
|
||||
it 'rejects invalid tokens' do
|
||||
|
|
@ -797,13 +1117,16 @@ RSpec.describe TournamentsController, type: :controller do
|
|||
expect(json[:id]).to eq(@tournament.id)
|
||||
expect(json[:sync_last_pushed_at]).to eq(pushed_at.iso8601)
|
||||
expect(json[:sync_last_push_error]).to be_nil
|
||||
expect(json[:sync_auth_configured]).to eq(true)
|
||||
expect(json).not_to have_key(:sync_auth_token)
|
||||
expect(json).not_to have_key(:sync_auth_token_digest)
|
||||
expect(TournamentSyncPusher).to have_received(:push!).with(@tournament)
|
||||
end
|
||||
|
||||
it 'rejects sync test without complete leader configuration' do
|
||||
post :test_sync, params: { id: @tournament.to_param }
|
||||
|
||||
expect(response).to have_http_status(:unprocessable_entity)
|
||||
expect(response).to have_http_status(:unprocessable_content)
|
||||
expect(deserialize_response(response)[:error])
|
||||
.to eq('Tournament sync test requires writable tournament with sync_target_url and sync_auth_token')
|
||||
end
|
||||
|
|
@ -828,4 +1151,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
|
||||
|
|
|
|||
|
|
@ -20,7 +20,7 @@ RSpec.describe 'Backend API surface HTTP E2E' do
|
|||
|
||||
let(:owner_client) { login_client(email: owner_email, password: owner_password) }
|
||||
let(:other_client) { secondary_client }
|
||||
let(:anonymous_client) { TurniereE2E::ApiClient.new(base_url:) }
|
||||
let(:anonymous_client) { TurniereE2E::ApiClient.new(base_url: base_url) }
|
||||
|
||||
it 'reports health' do
|
||||
response = anonymous_client.get('/healthz')
|
||||
|
|
@ -33,7 +33,9 @@ RSpec.describe 'Backend API surface HTTP E2E' do
|
|||
response = anonymous_client.get('/version')
|
||||
|
||||
expect(response[:status]).to eq(200)
|
||||
expect(response.dig(:json, :git_commit_sha)).to match(/\A[0-9a-f]{40}\z/)
|
||||
sha = response.dig(:json, :git_commit_sha)
|
||||
expect(sha).to match(/\A[0-9a-f]{40}\z/)
|
||||
expect(sha).not_to eq("aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa")
|
||||
end
|
||||
|
||||
it 'supports registration for new users and still requires confirmation for login' do
|
||||
|
|
@ -41,16 +43,16 @@ RSpec.describe 'Backend API surface HTTP E2E' do
|
|||
email = "http-e2e-#{unique}@example.com"
|
||||
password = 'password123'
|
||||
username = "http-e2e-#{unique}"
|
||||
client = TurniereE2E::ApiClient.new(base_url:)
|
||||
client = TurniereE2E::ApiClient.new(base_url: base_url)
|
||||
registration = client.post('/users', body: {
|
||||
email:,
|
||||
password:,
|
||||
email: email,
|
||||
password: password,
|
||||
password_confirmation: password,
|
||||
username:
|
||||
username: username
|
||||
})
|
||||
login = TurniereE2E::ApiClient.new(base_url:).login!(email:, password:)
|
||||
login = TurniereE2E::ApiClient.new(base_url: base_url).login!(email: email, password: password)
|
||||
|
||||
expect([500, *200..299]).to include(registration[:status])
|
||||
expect(registration[:status]).to be_between(200, 299)
|
||||
expect(registration[:json]).not_to eq({})
|
||||
expect(login[:status]).to eq(401)
|
||||
end
|
||||
|
|
@ -66,12 +68,12 @@ RSpec.describe 'Backend API surface HTTP E2E' do
|
|||
invalid_index = anonymous_client.get('/tournaments', params: { type: 'bogus' })
|
||||
|
||||
expect(public_index[:status]).to eq(200)
|
||||
expect(public_index[:json].pluck(:id)).to include(public_tournament.fetch(:id))
|
||||
expect(public_index[:json].pluck(:id)).not_to include(private_tournament.fetch(:id))
|
||||
expect(public_index[:json].map { |tournament| tournament[:id] }).to include(public_tournament.fetch(:id))
|
||||
expect(public_index[:json].map { |tournament| tournament[:id] }).not_to include(private_tournament.fetch(:id))
|
||||
|
||||
expect(private_index[:status]).to eq(200)
|
||||
expect(private_index[:json].pluck(:id)).to include(private_tournament.fetch(:id))
|
||||
expect(private_index[:json].pluck(:id)).not_to include(public_tournament.fetch(:id))
|
||||
expect(private_index[:json].map { |tournament| tournament[:id] }).to include(private_tournament.fetch(:id))
|
||||
expect(private_index[:json].map { |tournament| tournament[:id] }).not_to include(public_tournament.fetch(:id))
|
||||
|
||||
expect(simple_show[:status]).to eq(200)
|
||||
expect(simple_show[:json][:id]).to eq(public_tournament.fetch(:id))
|
||||
|
|
@ -80,6 +82,10 @@ RSpec.describe 'Backend API surface HTTP E2E' do
|
|||
expect(full_show[:status]).to eq(200)
|
||||
expect(full_show.dig(:json, :stages)).to be_an(Array)
|
||||
expect(full_show.dig(:json, :teams)).to be_an(Array)
|
||||
expect(full_show.dig(:json, :location)).to start_with('Venue')
|
||||
expect(full_show.dig(:json, :starts_at)).to eq('2030-06-01T08:00:00Z')
|
||||
expect(full_show.dig(:json, :ends_at)).to eq('2030-06-01T16:00:00Z')
|
||||
expect(full_show.dig(:json, :website_url)).to eq('https://example.com/turniere-e2e')
|
||||
|
||||
expect(invalid_index[:status]).to eq(400)
|
||||
expect(invalid_index.dig(:json, :error)).to eq('invalid type')
|
||||
|
|
@ -100,10 +106,10 @@ RSpec.describe 'Backend API surface HTTP E2E' do
|
|||
teams: [{ name: 'Solo Team' }]
|
||||
})
|
||||
|
||||
expect(group_stage.fetch(:stages).pluck(:level)).to include(-1)
|
||||
expect(group_stage.fetch(:stages).map { |stage| stage[:level] }).to include(-1)
|
||||
expect(group_stage.fetch(:teams).count).to eq(16)
|
||||
|
||||
expect(playoff_only.fetch(:stages).pluck(:level).sort).to eq([0, 1])
|
||||
expect(playoff_only.fetch(:stages).map { |stage| stage[:level] }.sort).to eq([0, 1])
|
||||
expect(playoff_only.fetch(:teams).count).to eq(4)
|
||||
|
||||
expect(invalid_teams[:status]).to eq(422)
|
||||
|
|
@ -112,11 +118,79 @@ RSpec.describe 'Backend API surface HTTP E2E' do
|
|||
expect(unauthenticated[:status]).to eq(401)
|
||||
end
|
||||
|
||||
it 'links player accounts to teams and exposes public account profiles' do
|
||||
unless ENV['TURNIERE_E2E_ALT_EMAIL'] && ENV['TURNIERE_E2E_ALT_USERNAME']
|
||||
skip('player profile flow requires TURNIERE_E2E_ALT_EMAIL and TURNIERE_E2E_ALT_USERNAME')
|
||||
end
|
||||
|
||||
unique = unique_suffix
|
||||
player_email = ENV.fetch('TURNIERE_E2E_ALT_EMAIL')
|
||||
player_username = ENV.fetch('TURNIERE_E2E_ALT_USERNAME')
|
||||
player_client = other_client
|
||||
player_created_tournament = create_playoff_tournament(
|
||||
client: player_client,
|
||||
public: true,
|
||||
name_prefix: 'Player Created'
|
||||
)
|
||||
tournament = create_playoff_tournament(client: owner_client, public: true, name_prefix: 'Player Profile')
|
||||
linked_team = tournament.fetch(:teams).first
|
||||
unlinked_team = tournament.fetch(:teams).fetch(1)
|
||||
|
||||
link_existing = owner_client.patch("/teams/#{linked_team.fetch(:id)}", body: { player_email: player_email })
|
||||
player_update = player_client.patch("/teams/#{linked_team.fetch(:id)}", body: {
|
||||
name: "#{linked_team.fetch(:name)} Player Rename"
|
||||
})
|
||||
player_reassign = player_client.patch("/teams/#{linked_team.fetch(:id)}", body: {
|
||||
player_email: "stolen-#{unique}@example.com"
|
||||
})
|
||||
co_player_email = "http-e2e-co-player-#{unique}@example.com"
|
||||
link_co_player = owner_client.patch("/teams/#{linked_team.fetch(:id)}", body: {
|
||||
player_email: co_player_email
|
||||
})
|
||||
placeholder_email = "http-e2e-placeholder-#{unique}@example.com"
|
||||
link_placeholder = owner_client.patch("/teams/#{unlinked_team.fetch(:id)}", body: {
|
||||
player_email: placeholder_email
|
||||
})
|
||||
profile = anonymous_client.get("/accounts/#{player_id_by_name(link_existing, player_username)}")
|
||||
placeholder_profile = anonymous_client.get("/accounts/#{player_id_by_name(link_placeholder, placeholder_email)}")
|
||||
|
||||
expect(player_client.authenticated?).to eq(true)
|
||||
expect(linked_team[:players]).to eq([])
|
||||
|
||||
expect(link_existing[:status]).to eq(200)
|
||||
expect(link_existing.dig(:json, :players).map { |player| player.fetch(:name) }).to eq([player_username])
|
||||
|
||||
expect(player_update[:status]).to eq(200)
|
||||
expect(player_update.dig(:json, :name)).to end_with('Player Rename')
|
||||
expect(player_reassign[:status]).to eq(403)
|
||||
|
||||
expect(link_co_player[:status]).to eq(200)
|
||||
co_player_names = link_co_player.dig(:json, :players).map { |player| player.fetch(:name) }
|
||||
expect(co_player_names).to include(player_username)
|
||||
expect(co_player_names.any? { |name| name.start_with?('http-e2e-co-player') }).to eq(true)
|
||||
|
||||
expect(link_placeholder[:status]).to eq(200)
|
||||
expect(link_placeholder.dig(:json, :players).first[:name]).to start_with('http-e2e-placeholder')
|
||||
expect(placeholder_profile[:status]).to eq(200)
|
||||
expect(placeholder_profile.dig(:json, :teams).map { |team| team.fetch(:id) }).to include(unlinked_team.fetch(:id))
|
||||
|
||||
expect(profile[:status]).to eq(200)
|
||||
expect(profile.dig(:json, :name)).to eq(player_username)
|
||||
expect(profile.dig(:json, :teams).map { |team| team.fetch(:id) }).to include(linked_team.fetch(:id))
|
||||
expect(profile.dig(:json, :tournaments).map { |item| item.fetch(:id) }).to include(tournament.fetch(:id))
|
||||
expect(profile.dig(:json, :created_tournaments).map { |item| item.fetch(:id) })
|
||||
.to include(player_created_tournament.fetch(:id))
|
||||
end
|
||||
|
||||
it 'updates and destroys tournaments, enforces owner checks, and validates timer updates' do
|
||||
tournament = create_group_stage_tournament(client: owner_client, public: false, name_prefix: 'Mutable Tournament')
|
||||
|
||||
update_response = owner_client.patch("/tournaments/#{tournament.fetch(:id)}", body: {
|
||||
name: "Renamed #{unique_suffix}",
|
||||
location: 'Updated E2E Arena',
|
||||
starts_at: '2030-07-01T10:00:00+02:00',
|
||||
ends_at: '2030-07-01T18:00:00+02:00',
|
||||
website_url: 'https://example.com/updated-turniere-e2e',
|
||||
playoff_teams_amount: 8,
|
||||
instant_finalists_amount: 8,
|
||||
intermediate_round_participants_amount: 0
|
||||
|
|
@ -132,16 +206,39 @@ RSpec.describe 'Backend API surface HTTP E2E' do
|
|||
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' })
|
||||
|
||||
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_countup = owner_client.patch("/tournaments/#{tournament.fetch(:id)}/timer", body: { timer_mode: 'countup' })
|
||||
timer_reason_custom = owner_client.patch("/tournaments/#{tournament.fetch(:id)}/timer", body: {
|
||||
timer_reason: 'custom',
|
||||
timer_reason_text: 'Pause ran long'
|
||||
})
|
||||
timer_reason_remove = owner_client.patch("/tournaments/#{tournament.fetch(:id)}/timer", body: {
|
||||
timer_reason: nil
|
||||
})
|
||||
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_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)}/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_countup_seconds = owner_client.patch("/tournaments/#{tournament.fetch(:id)}/timer", body: {
|
||||
timer_mode: 'countup',
|
||||
|
|
@ -154,6 +251,10 @@ RSpec.describe 'Backend API surface HTTP E2E' do
|
|||
|
||||
expect(update_response[:status]).to eq(200)
|
||||
expect(update_response.dig(:json, :name)).to start_with('Renamed')
|
||||
expect(update_response.dig(:json, :location)).to eq('Updated E2E Arena')
|
||||
expect(update_response.dig(:json, :starts_at)).to eq('2030-07-01T08:00:00Z')
|
||||
expect(update_response.dig(:json, :ends_at)).to eq('2030-07-01T16:00:00Z')
|
||||
expect(update_response.dig(:json, :website_url)).to eq('https://example.com/updated-turniere-e2e')
|
||||
|
||||
expect(auto_recalc_response[:status]).to eq(200)
|
||||
expect(auto_recalc_response.dig(:json, :playoff_teams_amount)).to eq(4)
|
||||
|
|
@ -170,18 +271,41 @@ RSpec.describe 'Backend API surface HTTP E2E' do
|
|||
expect(timer_seconds[:status]).to eq(200)
|
||||
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_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.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_reason)).to eq('tournament_start')
|
||||
expect(timer_reason_custom[:status]).to eq(200)
|
||||
expect(timer_reason_custom.dig(:json, :timestamp)).to eq(timer_seconds.dig(:json, :timestamp))
|
||||
expect(timer_reason_custom.dig(:json, :timer_mode)).to eq('countdown')
|
||||
expect(timer_reason_custom.dig(:json, :timer_reason)).to eq('custom')
|
||||
expect(timer_reason_custom.dig(:json, :timer_reason_text)).to eq('Pause ran long')
|
||||
expect(timer_reason_remove[:status]).to eq(200)
|
||||
expect(timer_reason_remove.dig(:json, :timestamp)).to eq(timer_seconds.dig(:json, :timestamp))
|
||||
expect(timer_reason_remove.dig(:json, :timer_mode)).to eq('countdown')
|
||||
expect(timer_reason_remove.dig(:json, :timer_reason)).to be_nil
|
||||
expect(timer_reason_remove.dig(:json, :timer_reason_text)).to be_nil
|
||||
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_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.dig(:json, :timestamp)).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.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.dig(:json, :error)).to eq('Invalid seconds format')
|
||||
expect(timer_past[:status]).to eq(422)
|
||||
|
|
@ -209,10 +333,8 @@ RSpec.describe 'Backend API surface HTTP E2E' do
|
|||
|
||||
statistics_before = anonymous_client.get("/tournaments/#{tournament.fetch(:id)}/statistics")
|
||||
matches_all = anonymous_client.get("/tournaments/#{tournament.fetch(:id)}/matches")
|
||||
matches_not_started = anonymous_client.get("/tournaments/#{tournament.fetch(:id)}/matches",
|
||||
params: { state: 'not_started' })
|
||||
matches_upcoming = anonymous_client.get("/tournaments/#{tournament.fetch(:id)}/matches",
|
||||
params: { state: 'upcoming' })
|
||||
matches_not_started = anonymous_client.get("/tournaments/#{tournament.fetch(:id)}/matches", params: { state: 'not_started' })
|
||||
matches_upcoming = anonymous_client.get("/tournaments/#{tournament.fetch(:id)}/matches", params: { state: 'upcoming' })
|
||||
matches_group_position = anonymous_client.get(
|
||||
"/tournaments/#{tournament.fetch(:id)}/matches",
|
||||
params: { group_stage_position: match.fetch(:position) }
|
||||
|
|
@ -224,8 +346,7 @@ RSpec.describe 'Backend API surface HTTP E2E' do
|
|||
match_show = anonymous_client.get("/matches/#{match.fetch(:id)}")
|
||||
invalid_match_update = owner_client.patch("/matches/#{match.fetch(:id)}", body: { state: 'bogus' })
|
||||
forbidden_match_update = other_client.patch("/matches/#{match.fetch(:id)}", body: { state: 'in_progress' })
|
||||
unauthenticated_match_update = anonymous_client.patch("/matches/#{match.fetch(:id)}",
|
||||
body: { state: 'in_progress' })
|
||||
unauthenticated_match_update = anonymous_client.patch("/matches/#{match.fetch(:id)}", body: { state: 'in_progress' })
|
||||
stage_not_over = owner_client.patch("/stages/#{group_stage.fetch(:id)}", body: { state: 'finished' })
|
||||
invalid_stage_update = owner_client.patch("/stages/#{group_stage.fetch(:id)}", body: { state: 'in_progress' })
|
||||
forbidden_stage_update = other_client.patch("/stages/#{group_stage.fetch(:id)}", body: { state: 'finished' })
|
||||
|
|
@ -242,12 +363,14 @@ RSpec.describe 'Backend API surface HTTP E2E' do
|
|||
started_match = anonymous_client.get("/matches/#{match.fetch(:id)}")
|
||||
score_to_update = started_match.fetch(:json).fetch(:match_scores).first
|
||||
score_show = anonymous_client.get("/match_scores/#{score_to_update.fetch(:id)}")
|
||||
score_update = owner_client.patch("/match_scores/#{score_to_update.fetch(:id)}", body: { points: 7 })
|
||||
score_update = owner_client.patch("/match_scores/#{score_to_update.fetch(:id)}", body: { points: 7, hidden_points: 11 })
|
||||
forbidden_score_update = other_client.patch("/match_scores/#{score_to_update.fetch(:id)}", body: { points: 3 })
|
||||
unauthenticated_score_update = anonymous_client.patch("/match_scores/#{score_to_update.fetch(:id)}",
|
||||
body: { points: 3 })
|
||||
unauthenticated_score_update = anonymous_client.patch("/match_scores/#{score_to_update.fetch(:id)}", body: { points: 3 })
|
||||
bet_while_running = owner_client.post("/matches/#{match.fetch(:id)}/bets", body: { team: team.fetch(:id) })
|
||||
restart_started_match = owner_client.patch("/matches/#{match.fetch(:id)}", body: { state: 'in_progress' })
|
||||
stop_started_match = owner_client.patch("/matches/#{match.fetch(:id)}", body: { state: 'not_started' })
|
||||
stopped_match = anonymous_client.get("/matches/#{match.fetch(:id)}")
|
||||
restop_match = owner_client.patch("/matches/#{match.fetch(:id)}", body: { state: 'not_started' })
|
||||
playoff_stats = anonymous_client.get("/tournaments/#{playoff_only.fetch(:id)}/statistics")
|
||||
tied_playoff_match = playoff_only.fetch(:stages)
|
||||
.flat_map { |stage| stage.fetch(:matches) }
|
||||
|
|
@ -288,9 +411,9 @@ RSpec.describe 'Backend API surface HTTP E2E' do
|
|||
expect(matches_all[:status]).to eq(200)
|
||||
expect(matches_all[:json].count).to eq(tournament_match_count(tournament))
|
||||
expect(matches_not_started[:status]).to eq(200)
|
||||
expect(matches_not_started[:json].pluck(:id)).to include(match.fetch(:id))
|
||||
expect(matches_not_started[:json].map { |item| item[:id] }).to include(match.fetch(:id))
|
||||
expect(matches_upcoming[:status]).to eq(200)
|
||||
expect(matches_upcoming[:json].pluck(:id)).to include(match.fetch(:id))
|
||||
expect(matches_upcoming[:json].map { |item| item[:id] }).to include(match.fetch(:id))
|
||||
expect(matches_group_position[:status]).to eq(200)
|
||||
expect(matches_group_position[:json].map { |item| item.dig(:stage, :level) }.uniq).to eq([-1])
|
||||
expect(matches_group_position[:json]).to all(include(position: match.fetch(:position)))
|
||||
|
|
@ -322,12 +445,21 @@ RSpec.describe 'Backend API surface HTTP E2E' do
|
|||
expect(score_show[:status]).to eq(200)
|
||||
expect(score_update[:status]).to eq(200)
|
||||
expect(score_update.dig(:json, :points)).to eq(7)
|
||||
expect(score_update.dig(:json, :hidden_points)).to eq(11)
|
||||
expect(forbidden_score_update[:status]).to eq(403)
|
||||
expect(unauthenticated_score_update[:status]).to eq(401)
|
||||
expect(bet_while_running[:status]).to eq(422)
|
||||
expect(bet_while_running.dig(:json, :error)).to eq('Betting is not allowed while match is in_progress')
|
||||
expect(restart_started_match[:status]).to eq(422)
|
||||
expect(restart_started_match.dig(:json, :error)).to eq("Match can't start in this state")
|
||||
expect(stop_started_match[:status]).to eq(200)
|
||||
expect(stop_started_match.dig(:json, :state)).to eq('not_started')
|
||||
expect(stopped_match[:status]).to eq(200)
|
||||
expect(stopped_match.dig(:json, :state)).to eq('not_started')
|
||||
expect(stopped_match.dig(:json, :match_scores).map { |score| score.fetch(:points) }).to all(eq(0))
|
||||
expect(stopped_match.dig(:json, :match_scores).map { |score| score.fetch(:hidden_points) }).to all(eq(0))
|
||||
expect(restop_match[:status]).to eq(422)
|
||||
expect(restop_match.dig(:json, :error)).to eq("Match can't stop in this state")
|
||||
|
||||
expect(playoff_stats[:status]).to eq(501)
|
||||
expect(playoff_stats[:json]).to eq({})
|
||||
|
|
@ -349,7 +481,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' })
|
||||
|
|
@ -362,38 +493,83 @@ RSpec.describe 'Backend API surface HTTP E2E' do
|
|||
.fetch(:groups)
|
||||
.find { |item| item.fetch(:id) == group.fetch(:id) }
|
||||
swapped_matches = swapped_group.fetch(:matches).sort_by { |item| item.fetch(:position) }
|
||||
upcoming_after_swap = owner_client.get("/tournaments/#{tournament.fetch(:id)}/matches",
|
||||
params: { state: 'upcoming' })
|
||||
upcoming_after_swap = owner_client.get("/tournaments/#{tournament.fetch(:id)}/matches", params: { state: 'upcoming' })
|
||||
|
||||
finish_group_match_with_winner!(match_id: fourth_match.fetch(:id))
|
||||
|
||||
upcoming_after_finish = owner_client.get("/tournaments/#{tournament.fetch(:id)}/matches",
|
||||
params: { state: 'upcoming' })
|
||||
upcoming_after_finish = owner_client.get("/tournaments/#{tournament.fetch(:id)}/matches", params: { state: 'upcoming' })
|
||||
|
||||
expect(upcoming_before[:status]).to eq(200)
|
||||
expect(upcoming_match_for_group(upcoming_before,
|
||||
group_id: group.fetch(:id)).fetch(:id)).to eq(first_match.fetch(:id))
|
||||
expect(upcoming_match_for_group(upcoming_before, group_id: group.fetch(:id)).fetch(:id)).to eq(first_match.fetch(:id))
|
||||
|
||||
expect(swap_response[:status]).to eq(200)
|
||||
expect(swap_response[:json].pluck(:id)).to match_array([first_match.fetch(:id), fourth_match.fetch(:id)])
|
||||
expect(swap_response[:json].map { |match| match[:id] }).to match_array([first_match.fetch(:id), fourth_match.fetch(:id)])
|
||||
|
||||
expect(swapped_matches.first.fetch(:id)).to eq(fourth_match.fetch(:id))
|
||||
expect(swapped_matches.first.fetch(:position)).to eq(first_match.fetch(:position))
|
||||
expect(swapped_matches.find do |match|
|
||||
match.fetch(:id) == first_match.fetch(:id)
|
||||
end.fetch(:position)).to eq(fourth_match.fetch(:position))
|
||||
expect(swapped_matches.find { |match| match.fetch(:id) == first_match.fetch(:id) }.fetch(:position)).to eq(fourth_match.fetch(:position))
|
||||
expect(upcoming_after_swap[:status]).to eq(200)
|
||||
expect(upcoming_match_for_group(upcoming_after_swap,
|
||||
group_id: group.fetch(:id)).fetch(:id)).to eq(fourth_match.fetch(:id))
|
||||
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 only a single lagging group before all groups can advance over HTTP' do
|
||||
tournament = create_group_stage_tournament(client: owner_client, public: false, name_prefix: 'Start Next Groups')
|
||||
groups = group_stage_groups(tournament)
|
||||
matches_by_group = indexed_group_matches(groups)
|
||||
start_group_positions!(matches_by_group:, group: groups[0], positions: [0, 1])
|
||||
start_group_positions!(matches_by_group:, group: groups[1], positions: [0])
|
||||
start_group_positions!(matches_by_group:, group: groups[2], positions: [0, 1])
|
||||
start_group_positions!(matches_by_group:, group: groups[3], positions: [0, 1])
|
||||
|
||||
first_expected_ids = match_ids_for_positions(matches_by_group:, pairs: [[groups[1], 1]])
|
||||
second_expected_ids = match_ids_for_positions(
|
||||
matches_by_group:,
|
||||
pairs: groups.map { |group| [group, 2] }
|
||||
)
|
||||
upcoming = owner_client.get("/tournaments/#{tournament.fetch(:id)}/matches", params: { state: 'upcoming' })
|
||||
first_start = owner_client.patch("/tournaments/#{tournament.fetch(:id)}/start_next_group_matches")
|
||||
second_start = owner_client.patch("/tournaments/#{tournament.fetch(:id)}/start_next_group_matches")
|
||||
|
||||
expect_match_ids(upcoming, first_expected_ids)
|
||||
expect_started_match_ids(first_start, first_expected_ids)
|
||||
expect_started_match_ids(second_start, second_expected_ids)
|
||||
end
|
||||
|
||||
it 'starts catch-up waves before the next position is started everywhere over HTTP' do
|
||||
tournament = create_group_stage_tournament(client: owner_client, public: false, name_prefix: 'Start Catch Up Waves')
|
||||
groups = group_stage_groups(tournament)
|
||||
matches_by_group = indexed_group_matches(groups)
|
||||
start_group_positions!(matches_by_group:, group: groups[0], positions: [0, 1, 2])
|
||||
start_group_positions!(matches_by_group:, group: groups[1], positions: [0])
|
||||
start_group_positions!(matches_by_group:, group: groups[2], positions: [0, 1, 2])
|
||||
start_group_positions!(matches_by_group:, group: groups[3], positions: [0, 1])
|
||||
|
||||
first_expected_ids = match_ids_for_positions(matches_by_group:, pairs: [[groups[1], 1]])
|
||||
second_expected_ids = match_ids_for_positions(
|
||||
matches_by_group:,
|
||||
pairs: [[groups[1], 2], [groups[3], 2]]
|
||||
)
|
||||
third_expected_ids = match_ids_for_positions(
|
||||
matches_by_group:,
|
||||
pairs: groups.map { |group| [group, 3] }
|
||||
)
|
||||
first_start = owner_client.patch("/tournaments/#{tournament.fetch(:id)}/start_next_group_matches")
|
||||
second_start = owner_client.patch("/tournaments/#{tournament.fetch(:id)}/start_next_group_matches")
|
||||
third_start = owner_client.patch("/tournaments/#{tournament.fetch(:id)}/start_next_group_matches")
|
||||
|
||||
expect_started_match_ids(first_start, first_expected_ids)
|
||||
expect_started_match_ids(second_start, second_expected_ids)
|
||||
expect_started_match_ids(third_start, third_expected_ids)
|
||||
end
|
||||
|
||||
def login_client(email:, password:)
|
||||
client = TurniereE2E::ApiClient.new(base_url:)
|
||||
response = client.login!(email:, password:)
|
||||
client = TurniereE2E::ApiClient.new(base_url: base_url)
|
||||
response = client.login!(email: email, password: password)
|
||||
|
||||
expect(response[:status]).to eq(200)
|
||||
expect(client.authenticated?).to eq(true)
|
||||
|
|
@ -401,15 +577,15 @@ RSpec.describe 'Backend API surface HTTP E2E' do
|
|||
end
|
||||
|
||||
def secondary_client
|
||||
alt_email = ENV.fetch('TURNIERE_E2E_ALT_EMAIL', nil)
|
||||
alt_password = ENV.fetch('TURNIERE_E2E_ALT_PASSWORD', nil)
|
||||
alt_email = ENV['TURNIERE_E2E_ALT_EMAIL']
|
||||
alt_password = ENV['TURNIERE_E2E_ALT_PASSWORD']
|
||||
if alt_email && alt_password
|
||||
client = TurniereE2E::ApiClient.new(base_url:)
|
||||
client = TurniereE2E::ApiClient.new(base_url: base_url)
|
||||
response = client.login!(email: alt_email, password: alt_password)
|
||||
return client if response[:status] == 200 && client.authenticated?
|
||||
end
|
||||
|
||||
client = TurniereE2E::ApiClient.new(base_url:)
|
||||
client = TurniereE2E::ApiClient.new(base_url: base_url)
|
||||
unique = unique_suffix
|
||||
response = client.post('/users', body: {
|
||||
email: "http-e2e-secondary-#{unique}@example.com",
|
||||
|
|
@ -427,11 +603,15 @@ RSpec.describe 'Backend API surface HTTP E2E' do
|
|||
payload = {
|
||||
name: "#{name_prefix} #{unique_suffix}",
|
||||
description: 'HTTP API surface E2E group-stage tournament',
|
||||
public:,
|
||||
public: public,
|
||||
location: "Venue #{name_prefix}",
|
||||
starts_at: '2030-06-01T10:00:00+02:00',
|
||||
ends_at: '2030-06-01T18:00:00+02:00',
|
||||
website_url: 'https://example.com/turniere-e2e',
|
||||
group_stage: true,
|
||||
playoff_teams_amount: 8,
|
||||
teams: 4.times.flat_map do |group_index|
|
||||
Array.new(4) do |seed_index|
|
||||
4.times.map do |seed_index|
|
||||
{
|
||||
name: "#{name_prefix.tr(' ', '')}-G#{group_index + 1}-S#{seed_index + 1}",
|
||||
group: group_index
|
||||
|
|
@ -442,22 +622,66 @@ RSpec.describe 'Backend API surface HTTP E2E' do
|
|||
|
||||
response = client.post('/tournaments', body: payload)
|
||||
expect(response[:status]).to eq(201)
|
||||
fetch_tournament(client:, tournament_id: response.dig(:json, :id))
|
||||
fetch_tournament(client: client, tournament_id: response.dig(:json, :id))
|
||||
end
|
||||
|
||||
def create_playoff_tournament(client:, public:, name_prefix:)
|
||||
def group_stage_groups(tournament)
|
||||
tournament.fetch(:stages).find { |stage| stage.fetch(:level) == -1 }
|
||||
.fetch(:groups)
|
||||
.sort_by { |group| group.fetch(:number) }
|
||||
end
|
||||
|
||||
def indexed_group_matches(groups)
|
||||
groups.to_h do |group|
|
||||
[group.fetch(:id), group.fetch(:matches).sort_by { |match| match.fetch(:position) }]
|
||||
end
|
||||
end
|
||||
|
||||
def start_group_positions!(matches_by_group:, group:, positions:)
|
||||
positions.each do |position|
|
||||
start_group_match!(match_id: matches_by_group.fetch(group.fetch(:id)).fetch(position).fetch(:id))
|
||||
end
|
||||
end
|
||||
|
||||
def match_ids_for_positions(matches_by_group:, pairs:)
|
||||
pairs.map do |group, position|
|
||||
matches_by_group.fetch(group.fetch(:id)).fetch(position).fetch(:id)
|
||||
end
|
||||
end
|
||||
|
||||
def expect_match_ids(response, expected_ids)
|
||||
expect(response[:status]).to eq(200)
|
||||
expect(response[:json].map { |match| match.fetch(:id) }).to match_array(expected_ids)
|
||||
end
|
||||
|
||||
def expect_started_match_ids(response, expected_ids)
|
||||
expect_match_ids(response, expected_ids)
|
||||
expect(response[:json].map { |match| match.fetch(:state) }.uniq).to eq(['in_progress'])
|
||||
end
|
||||
|
||||
def player_id_by_name(response, name)
|
||||
response.dig(:json, :players).find do |player|
|
||||
player.fetch(:name) == name || player.fetch(:name).start_with?(name.split('@').first)
|
||||
end.fetch(:id)
|
||||
end
|
||||
|
||||
def create_playoff_tournament(client:, public:, name_prefix:, teams: nil)
|
||||
payload = {
|
||||
name: "#{name_prefix} #{unique_suffix}",
|
||||
description: 'HTTP API surface E2E playoff tournament',
|
||||
public:,
|
||||
teams: Array.new(4) do |index|
|
||||
public: public,
|
||||
location: "Venue #{name_prefix}",
|
||||
starts_at: '2030-06-01T10:00:00+02:00',
|
||||
ends_at: '2030-06-01T18:00:00+02:00',
|
||||
website_url: 'https://example.com/turniere-e2e',
|
||||
teams: teams || 4.times.map do |index|
|
||||
{ name: "#{name_prefix.tr(' ', '')}-S#{index + 1}" }
|
||||
end
|
||||
}
|
||||
|
||||
response = client.post('/tournaments', body: payload)
|
||||
expect(response[:status]).to eq(201)
|
||||
fetch_tournament(client:, tournament_id: response.dig(:json, :id))
|
||||
fetch_tournament(client: client, tournament_id: response.dig(:json, :id))
|
||||
end
|
||||
|
||||
def fetch_tournament(client:, tournament_id:)
|
||||
|
|
@ -486,6 +710,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 }
|
||||
|
|
|
|||
|
|
@ -0,0 +1,36 @@
|
|||
# frozen_string_literal: true
|
||||
|
||||
require 'spec_helper'
|
||||
require_relative '../../../e2e/lib/load_test_runner'
|
||||
|
||||
RSpec.describe 'Load test HTTP E2E' do
|
||||
required_env_vars = %w[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', 'http://127.0.0.1:3000') }
|
||||
|
||||
it 'exercises concurrent tournament reads and websocket subscriptions' do
|
||||
result = TurniereE2E::LoadTestRunner.new(
|
||||
base_url: base_url,
|
||||
email: ENV.fetch('TURNIERE_E2E_EMAIL'),
|
||||
password: ENV.fetch('TURNIERE_E2E_PASSWORD'),
|
||||
username: ENV['TURNIERE_E2E_USERNAME'],
|
||||
http_clients: [1, 2],
|
||||
websocket_clients: [1, 2],
|
||||
http_requests_per_client: 1,
|
||||
websocket_hold_seconds: 0.1,
|
||||
group_count: 1,
|
||||
teams_per_group: 4,
|
||||
playoff_teams_amount: 2
|
||||
).run
|
||||
|
||||
expect(result.fetch(:http).map { |level| level.fetch(:errors) }).to all(eq(0))
|
||||
expect(result.fetch(:websocket).map { |level| level.fetch(:errors) }).to all(eq(0))
|
||||
expect(result.fetch(:websocket).map { |level| level.fetch(:opened) }).to eq([1, 2])
|
||||
expect(result.dig(:metrics, :action_cable_connections_total)).to be >= 2
|
||||
end
|
||||
end
|
||||
|
|
@ -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
|
||||
|
|
@ -86,7 +86,7 @@ RSpec.describe 'Tournament beamer websocket HTTP E2E' do
|
|||
name: "#{name_prefix} #{SecureRandom.hex(3)}",
|
||||
description: 'Websocket beamer tournament',
|
||||
public: true,
|
||||
teams: Array.new(4) do |index|
|
||||
teams: 4.times.map do |index|
|
||||
{ name: "#{name_prefix.tr(' ', '')}-S#{index + 1}" }
|
||||
end
|
||||
})
|
||||
|
|
@ -95,13 +95,13 @@ RSpec.describe 'Tournament beamer websocket HTTP E2E' do
|
|||
end
|
||||
|
||||
def wait_for_beamer_payload!(cable_client, event_type:, display_state:, timeout: 20)
|
||||
deadline = Time.zone.now + timeout
|
||||
deadline = Time.now + timeout
|
||||
loop do
|
||||
payload = cable_client.wait_for_beamer_payload!(timeout: [deadline - Time.zone.now, 0.1].max)
|
||||
payload = cable_client.wait_for_beamer_payload!(timeout: [deadline - Time.now, 0.1].max)
|
||||
return payload if payload.fetch('type') == event_type &&
|
||||
payload.dig('beamer', 'display_state') == display_state
|
||||
|
||||
raise "beamer websocket payload not received within #{timeout}s" if Time.zone.now >= deadline
|
||||
raise "beamer websocket payload not received within #{timeout}s" if Time.now >= deadline
|
||||
end
|
||||
end
|
||||
end
|
||||
|
|
|
|||
|
|
@ -85,7 +85,7 @@ RSpec.describe 'Tournament follower sync beamer websocket HTTP E2E' do
|
|||
name: "#{name_prefix} #{SecureRandom.hex(3)}",
|
||||
description: 'Follower beamer websocket source tournament',
|
||||
public: true,
|
||||
teams: Array.new(4) do |index|
|
||||
teams: 4.times.map do |index|
|
||||
{ name: "#{name_prefix.tr(' ', '')}-S#{index + 1}" }
|
||||
end
|
||||
})
|
||||
|
|
@ -125,25 +125,25 @@ RSpec.describe 'Tournament follower sync beamer websocket HTTP E2E' do
|
|||
end
|
||||
|
||||
def wait_for_default_beamer!(tournament_id:, timeout: 20)
|
||||
deadline = Time.zone.now + timeout
|
||||
deadline = Time.now + timeout
|
||||
loop do
|
||||
beamer = fetch_beamer(base_url: follower_base_url, tournament_id:, beamer_name: DEFAULT_BEAMER_NAME)
|
||||
return beamer if beamer.fetch(:name) == DEFAULT_BEAMER_NAME
|
||||
|
||||
raise "follower default beamer not present within #{timeout}s" if Time.zone.now >= deadline
|
||||
raise "follower default beamer not present within #{timeout}s" if Time.now >= deadline
|
||||
|
||||
sleep 0.5
|
||||
end
|
||||
end
|
||||
|
||||
def wait_for_beamer_payload!(cable_client, event_type:, display_state:, timeout: 20)
|
||||
deadline = Time.zone.now + timeout
|
||||
deadline = Time.now + timeout
|
||||
loop do
|
||||
payload = cable_client.wait_for_beamer_payload!(timeout: [deadline - Time.zone.now, 0.1].max)
|
||||
payload = cable_client.wait_for_beamer_payload!(timeout: [deadline - Time.now, 0.1].max)
|
||||
return payload if payload.fetch('type') == event_type &&
|
||||
payload.dig('beamer', 'display_state') == display_state
|
||||
|
||||
raise "follower beamer websocket payload not received within #{timeout}s" if Time.zone.now >= deadline
|
||||
raise "follower beamer websocket payload not received within #{timeout}s" if Time.now >= deadline
|
||||
end
|
||||
end
|
||||
end
|
||||
|
|
|
|||
|
|
@ -23,9 +23,7 @@ RSpec.describe 'Tournament follower sync HTTP E2E' do
|
|||
let(:owner_password) { ENV.fetch('TURNIERE_E2E_PASSWORD') }
|
||||
let(:sync_token) { "sync-#{SecureRandom.hex(8)}" }
|
||||
let(:source_owner_client) { login_client(base_url: source_base_url, email: owner_email, password: owner_password) }
|
||||
let(:follower_owner_client) do
|
||||
login_client(base_url: follower_base_url, email: owner_email, password: owner_password)
|
||||
end
|
||||
let(:follower_owner_client) { login_client(base_url: follower_base_url, email: owner_email, password: owner_password) }
|
||||
let(:source_anonymous_client) { TurniereE2E::ApiClient.new(base_url: source_base_url) }
|
||||
let(:follower_anonymous_client) { TurniereE2E::ApiClient.new(base_url: follower_base_url) }
|
||||
|
||||
|
|
@ -45,24 +43,68 @@ RSpec.describe 'Tournament follower sync HTTP E2E' do
|
|||
sync_auth_token: sync_token
|
||||
})
|
||||
expect(configure_sync[:status]).to eq(200)
|
||||
exercise_unrelated_requests!(source_noise:, follower_noise:)
|
||||
exercise_unrelated_requests!(source_noise: source_noise, follower_noise: follower_noise)
|
||||
|
||||
source = fetch_tournament(client: source_anonymous_client, tournament_id: source.fetch(:id))
|
||||
follower = wait_for_tournament_sync!(source_tournament_id: source.fetch(:id),
|
||||
follower_tournament_id: follower.fetch(:id))
|
||||
follower = wait_for_tournament_sync!(source_tournament_id: source.fetch(:id), follower_tournament_id: follower.fetch(:id))
|
||||
expect(tournament_signature(follower)).to eq(tournament_signature(source))
|
||||
expect(synced_team_id_pairs(source:, follower:)).not_to be_empty
|
||||
expect(synced_team_id_pairs(source: source, follower: follower)).not_to be_empty
|
||||
|
||||
log_phase!('update match metadata and verify follower mirrors it')
|
||||
source_group_stage = source.fetch(:stages).find { |stage| stage.fetch(:level) == -1 }
|
||||
first_source_group = source_group_stage.fetch(:groups).sort_by { |group| group.fetch(:number) }.first
|
||||
first_group_matches = first_source_group.fetch(:matches).sort_by { |match| match.fetch(:position) }
|
||||
single_metadata = source_owner_client.patch("/matches/#{first_group_matches.fetch(0).fetch(:id)}", body: {
|
||||
location: 'Main Hall',
|
||||
start_time: '17:00',
|
||||
notes: 'Opening match'
|
||||
})
|
||||
explicit_bulk_metadata = source_owner_client.patch("/tournaments/#{source.fetch(:id)}/matches/metadata", body: {
|
||||
match_ids: first_group_matches.values_at(1, 2).map { |match| match.fetch(:id) },
|
||||
location: 'Court B',
|
||||
start_time: '17:30'
|
||||
})
|
||||
group_position_metadata = source_owner_client.patch("/tournaments/#{source.fetch(:id)}/matches/metadata", body: {
|
||||
group_stage_position: 3,
|
||||
start_time: '18:00',
|
||||
notes: 'Group position 3 wave'
|
||||
})
|
||||
expect(single_metadata[:status]).to eq(200)
|
||||
expect(single_metadata.dig(:json, :location)).to eq('Main Hall')
|
||||
expect(explicit_bulk_metadata[:status]).to eq(200)
|
||||
expect(explicit_bulk_metadata[:json].map { |match| match.fetch(:location) }).to all(eq('Court B'))
|
||||
expect(group_position_metadata[:status]).to eq(200)
|
||||
expect(group_position_metadata[:json].map { |match| match.fetch(:start_time) }).to all(eq('18:00'))
|
||||
|
||||
source = fetch_tournament(client: source_anonymous_client, tournament_id: source.fetch(:id))
|
||||
follower = wait_for_tournament_sync!(source_tournament_id: source.fetch(:id), follower_tournament_id: follower.fetch(:id))
|
||||
expect(tournament_signature(follower)).to eq(tournament_signature(source))
|
||||
|
||||
follower_group_stage = follower.fetch(:stages).find { |stage| stage.fetch(:level) == -1 }
|
||||
follower_group = follower_group_stage.fetch(:groups).sort_by { |group| group.fetch(:number) }.first
|
||||
follower_first_match = follower_group.fetch(:matches).sort_by { |match| match.fetch(:position) }.first
|
||||
locked_match_metadata = follower_owner_client.patch("/matches/#{follower_first_match.fetch(:id)}", body: {
|
||||
location: 'Follower edit'
|
||||
})
|
||||
locked_bulk_metadata = follower_owner_client.patch("/tournaments/#{follower.fetch(:id)}/matches/metadata", body: {
|
||||
match_ids: [follower_first_match.fetch(:id)],
|
||||
start_time: '19:00'
|
||||
})
|
||||
expect(locked_match_metadata[:status]).to eq(423)
|
||||
expect(locked_match_metadata.dig(:json, :error)).to eq('Tournament is in read only follower mode')
|
||||
expect(locked_bulk_metadata[:status]).to eq(423)
|
||||
expect(locked_bulk_metadata.dig(:json, :error)).to eq('Tournament is in read only follower mode')
|
||||
|
||||
log_phase!('swap matches on leader and verify follower behavior')
|
||||
source_group_stage = source.fetch(:stages).find { |stage| stage.fetch(:level) == -1 }
|
||||
first_source_group = source_group_stage.fetch(:groups).min_by { |group| group.fetch(:number) }
|
||||
first_source_group = source_group_stage.fetch(:groups).sort_by { |group| group.fetch(:number) }.first
|
||||
second_source_group = source_group_stage.fetch(:groups).sort_by { |group| group.fetch(:number) }[1]
|
||||
first_group_matches = first_source_group.fetch(:matches).sort_by { |match| match.fetch(:position) }
|
||||
first_position_match = first_group_matches.fetch(0)
|
||||
fourth_position_match = first_group_matches.fetch(3)
|
||||
wrong_group_match = second_source_group.fetch(:matches).min_by { |match| match.fetch(:position) }
|
||||
wrong_group_match = second_source_group.fetch(:matches).sort_by { |match| match.fetch(:position) }.first
|
||||
follower_group_stage = follower.fetch(:stages).find { |stage| stage.fetch(:level) == -1 }
|
||||
first_follower_group = follower_group_stage.fetch(:groups).min_by { |group| group.fetch(:number) }
|
||||
first_follower_group = follower_group_stage.fetch(:groups).sort_by { |group| group.fetch(:number) }.first
|
||||
follower_first_position_match = first_follower_group.fetch(:matches).find do |match|
|
||||
match_team_names(match) == match_team_names(first_position_match)
|
||||
end
|
||||
|
|
@ -79,18 +121,15 @@ RSpec.describe 'Tournament follower sync HTTP E2E' do
|
|||
fourth_position_match.fetch(:position)
|
||||
])
|
||||
source = fetch_tournament(client: source_anonymous_client, tournament_id: source.fetch(:id))
|
||||
follower = wait_for_tournament_sync!(source_tournament_id: source.fetch(:id),
|
||||
follower_tournament_id: follower.fetch(:id))
|
||||
follower = wait_for_tournament_sync!(source_tournament_id: source.fetch(:id), follower_tournament_id: follower.fetch(:id))
|
||||
expect(tournament_signature(follower)).to eq(tournament_signature(source))
|
||||
expect(group_match_positions(source, group_number: first_source_group.fetch(:number))).to eq(
|
||||
group_match_positions(follower, group_number: first_follower_group.fetch(:number))
|
||||
)
|
||||
expect(group_match_positions(source,
|
||||
group_number: first_source_group.fetch(:number)).fetch(match_team_names(first_position_match))).to eq(
|
||||
expect(group_match_positions(source, group_number: first_source_group.fetch(:number)).fetch(match_team_names(first_position_match))).to eq(
|
||||
fourth_position_match.fetch(:position)
|
||||
)
|
||||
expect(group_match_positions(source,
|
||||
group_number: first_source_group.fetch(:number)).fetch(match_team_names(fourth_position_match))).to eq(
|
||||
expect(group_match_positions(source, group_number: first_source_group.fetch(:number)).fetch(match_team_names(fourth_position_match))).to eq(
|
||||
first_position_match.fetch(:position)
|
||||
)
|
||||
|
||||
|
|
@ -103,8 +142,7 @@ RSpec.describe 'Tournament follower sync HTTP E2E' do
|
|||
expect(wrong_group_swap[:status]).to eq(422)
|
||||
expect(wrong_group_swap.dig(:json, :error)).to eq('Matches must be different group matches from the same group')
|
||||
source_after_invalid_swap = fetch_tournament(client: source_anonymous_client, tournament_id: source.fetch(:id))
|
||||
follower_after_invalid_swap = fetch_tournament(client: follower_anonymous_client,
|
||||
tournament_id: follower.fetch(:id))
|
||||
follower_after_invalid_swap = fetch_tournament(client: follower_anonymous_client, tournament_id: follower.fetch(:id))
|
||||
expect(tournament_signature(source_after_invalid_swap)).to eq(source_before_invalid_swap)
|
||||
expect(tournament_signature(follower_after_invalid_swap)).to eq(follower_before_invalid_swap)
|
||||
|
||||
|
|
@ -118,12 +156,16 @@ RSpec.describe 'Tournament follower sync HTTP E2E' do
|
|||
|
||||
log_phase!('tighten playoff cutoff and rename teams')
|
||||
update_cutoff = source_owner_client.patch("/tournaments/#{source.fetch(:id)}", body: {
|
||||
location: 'Source Updated Arena',
|
||||
starts_at: '2030-07-01T10:00:00+02:00',
|
||||
ends_at: '2030-07-01T18:00:00+02:00',
|
||||
website_url: 'https://example.com/updated-sync-source',
|
||||
playoff_teams_amount: 4,
|
||||
instant_finalists_amount: 4,
|
||||
intermediate_round_participants_amount: 0
|
||||
})
|
||||
expect(update_cutoff[:status]).to eq(200)
|
||||
exercise_unrelated_requests!(source_noise:, follower_noise:)
|
||||
exercise_unrelated_requests!(source_noise: source_noise, follower_noise: follower_noise)
|
||||
|
||||
source_group_stage = fetch_tournament(client: source_anonymous_client, tournament_id: source.fetch(:id))
|
||||
.fetch(:stages).find { |stage| stage.fetch(:level) == -1 }
|
||||
|
|
@ -134,35 +176,43 @@ RSpec.describe 'Tournament follower sync HTTP E2E' do
|
|||
group_team_map(source_group_stage.fetch(:groups).last).fetch(2).fetch(:name)
|
||||
]
|
||||
)
|
||||
source_noise.concat(seed_unrelated_tournaments!(client: source_owner_client, prefix: 'Source Midway Noise',
|
||||
range: 2..3))
|
||||
follower_noise.concat(seed_unrelated_tournaments!(client: follower_owner_client, prefix: 'Follower Midway Noise',
|
||||
range: 3..4))
|
||||
exercise_unrelated_requests!(source_noise:, follower_noise:)
|
||||
source_noise.concat(seed_unrelated_tournaments!(client: source_owner_client, prefix: 'Source Midway Noise', range: 2..3))
|
||||
follower_noise.concat(seed_unrelated_tournaments!(client: follower_owner_client, prefix: 'Follower Midway Noise', range: 3..4))
|
||||
exercise_unrelated_requests!(source_noise: source_noise, follower_noise: follower_noise)
|
||||
|
||||
log_phase!('play group stage with deciders')
|
||||
play_group_with_decider_lifecycle!(source_id: source.fetch(:id), groups: source_group_stage.fetch(:groups).sort_by do |group|
|
||||
group.fetch(:number)
|
||||
end)
|
||||
exercise_unrelated_requests!(source_noise:, follower_noise:)
|
||||
play_group_with_decider_lifecycle!(source_id: source.fetch(:id), groups: source_group_stage.fetch(:groups).sort_by { |group| group.fetch(:number) })
|
||||
exercise_unrelated_requests!(source_noise: source_noise, follower_noise: follower_noise)
|
||||
|
||||
log_phase!('verify playoff generation replicated')
|
||||
source = fetch_tournament(client: source_anonymous_client, tournament_id: source.fetch(:id))
|
||||
follower = wait_for_tournament_sync!(source_tournament_id: source.fetch(:id),
|
||||
follower_tournament_id: follower.fetch(:id))
|
||||
follower = wait_for_tournament_sync!(source_tournament_id: source.fetch(:id), follower_tournament_id: follower.fetch(:id))
|
||||
expect(tournament_signature(follower)).to eq(tournament_signature(source))
|
||||
expect(follower.fetch(:stages).map { |stage| stage.fetch(:level) }).to include(-1, 0, 1)
|
||||
expect(synced_team_id_pairs(source:, follower:)).not_to be_empty
|
||||
expect(synced_team_id_pairs(source: source, follower: follower)).not_to be_empty
|
||||
expect(source.fetch(:team_action_lists)).to eq([])
|
||||
expect(follower.fetch(:team_action_lists)).to eq([])
|
||||
|
||||
log_phase!('bulk update playoff match metadata and verify follower mirrors it')
|
||||
source_playoff_stage = source.fetch(:stages).select { |stage| stage.fetch(:level) >= 0 }.max_by { |stage| stage.fetch(:level) }
|
||||
playoff_metadata = source_owner_client.patch("/tournaments/#{source.fetch(:id)}/matches/metadata", body: {
|
||||
stage_level: source_playoff_stage.fetch(:level),
|
||||
location: 'Finals Arena',
|
||||
start_time: '21:00',
|
||||
notes: 'Playoff wave'
|
||||
})
|
||||
expect(playoff_metadata[:status]).to eq(200)
|
||||
expect(playoff_metadata[:json].map { |match| match.fetch(:location) }).to all(eq('Finals Arena'))
|
||||
source = fetch_tournament(client: source_anonymous_client, tournament_id: source.fetch(:id))
|
||||
follower = wait_for_tournament_sync!(source_tournament_id: source.fetch(:id), follower_tournament_id: follower.fetch(:id))
|
||||
expect(tournament_signature(follower)).to eq(tournament_signature(source))
|
||||
|
||||
log_phase!('create team action list on source and verify follower mirrors it')
|
||||
created_list = create_team_action_list!(source.fetch(:id))
|
||||
expect(created_list.fetch(:key)).to eq(playoff_token_list_key)
|
||||
|
||||
source = fetch_tournament(client: source_anonymous_client, tournament_id: source.fetch(:id))
|
||||
follower = wait_for_tournament_sync!(source_tournament_id: source.fetch(:id),
|
||||
follower_tournament_id: follower.fetch(:id))
|
||||
follower = wait_for_tournament_sync!(source_tournament_id: source.fetch(:id), follower_tournament_id: follower.fetch(:id))
|
||||
expect(playoff_token_list(source)).not_to be_nil
|
||||
expect(playoff_token_list(follower)).not_to be_nil
|
||||
expect(playoff_token_list(follower).fetch(:team_action_items).map { |item| item.dig(:team, :name) })
|
||||
|
|
@ -186,80 +236,135 @@ RSpec.describe 'Tournament follower sync HTTP E2E' do
|
|||
log_phase!('mark playoff token collected on source through business key route and verify follower mirrors it')
|
||||
source_token_item = playoff_token_list(source).fetch(:team_action_items).first
|
||||
source_token_update = source_owner_client.patch(
|
||||
"/tournaments/#{source.fetch(:id)}/team_action_lists/#{playoff_token_list_key}/teams/#{source_token_item.dig(
|
||||
:team, :id
|
||||
)}",
|
||||
"/tournaments/#{source.fetch(:id)}/team_action_lists/#{playoff_token_list_key}/teams/#{source_token_item.dig(:team, :id)}",
|
||||
body: { completed: true }
|
||||
)
|
||||
expect(source_token_update[:status]).to eq(200)
|
||||
|
||||
source = fetch_tournament(client: source_anonymous_client, tournament_id: source.fetch(:id))
|
||||
follower = wait_for_tournament_sync!(source_tournament_id: source.fetch(:id),
|
||||
follower_tournament_id: follower.fetch(:id))
|
||||
follower = wait_for_tournament_sync!(source_tournament_id: source.fetch(:id), follower_tournament_id: follower.fetch(:id))
|
||||
expect(playoff_token_item_completed?(source, team_name: source_token_item.dig(:team, :name))).to eq(true)
|
||||
expect(playoff_token_item_completed?(follower, team_name: source_token_item.dig(:team, :name))).to eq(true)
|
||||
|
||||
follower_token_item = find_team_action_item(follower, team_name: source_token_item.dig(:team, :name))
|
||||
locked_token_update = follower_owner_client.patch(
|
||||
"/tournaments/#{follower.fetch(:id)}/team_action_lists/#{playoff_token_list_key}/teams/#{follower_token_item.dig(
|
||||
:team, :id
|
||||
)}",
|
||||
"/tournaments/#{follower.fetch(:id)}/team_action_lists/#{playoff_token_list_key}/teams/#{follower_token_item.dig(:team, :id)}",
|
||||
body: { completed: false }
|
||||
)
|
||||
expect(locked_token_update[:status]).to eq(423)
|
||||
expect(locked_token_update.dig(:json, :error)).to eq('Tournament is in read only follower mode')
|
||||
|
||||
source_noise.concat(seed_unrelated_tournaments!(client: source_owner_client, prefix: 'Source Playoff Noise',
|
||||
range: 2..4))
|
||||
follower_noise.concat(seed_unrelated_tournaments!(client: follower_owner_client, prefix: 'Follower Playoff Noise',
|
||||
range: 2..5))
|
||||
exercise_unrelated_requests!(source_noise:, follower_noise:)
|
||||
source_noise.concat(seed_unrelated_tournaments!(client: source_owner_client, prefix: 'Source Playoff Noise', range: 2..4))
|
||||
follower_noise.concat(seed_unrelated_tournaments!(client: follower_owner_client, prefix: 'Follower Playoff Noise', range: 2..5))
|
||||
exercise_unrelated_requests!(source_noise: source_noise, follower_noise: follower_noise)
|
||||
|
||||
log_phase!('finish playoff bracket')
|
||||
finish_playoff_bracket!(source_id: source.fetch(:id))
|
||||
exercise_unrelated_requests!(source_noise:, follower_noise:)
|
||||
exercise_unrelated_requests!(source_noise: source_noise, follower_noise: follower_noise)
|
||||
|
||||
log_phase!('verify final follower state and takeover')
|
||||
source = fetch_tournament(client: source_anonymous_client, tournament_id: source.fetch(:id))
|
||||
follower = wait_for_tournament_sync!(source_tournament_id: source.fetch(:id),
|
||||
follower_tournament_id: follower.fetch(:id))
|
||||
follower = wait_for_tournament_sync!(source_tournament_id: source.fetch(:id), follower_tournament_id: follower.fetch(:id))
|
||||
expect(tournament_signature(follower)).to eq(tournament_signature(source))
|
||||
expect(synced_team_id_pairs(source:, follower:)).not_to be_empty
|
||||
expect(synced_team_id_pairs(source: source, follower: follower)).not_to be_empty
|
||||
|
||||
disable_follower = follower_owner_client.patch("/tournaments/#{follower.fetch(:id)}",
|
||||
body: { read_only_mode: false })
|
||||
disable_follower = follower_owner_client.patch("/tournaments/#{follower.fetch(:id)}", body: { read_only_mode: false })
|
||||
expect(disable_follower[:status]).to eq(200)
|
||||
|
||||
follower_after_disable = fetch_tournament(client: follower_anonymous_client, tournament_id: follower.fetch(:id))
|
||||
writable_team = follower_after_disable.fetch(:teams).first
|
||||
takeover_update = follower_owner_client.patch("/teams/#{writable_team.fetch(:id)}",
|
||||
body: { name: "#{writable_team.fetch(:name)} takeover" })
|
||||
takeover_update = follower_owner_client.patch("/teams/#{writable_team.fetch(:id)}", body: { name: "#{writable_team.fetch(:name)} takeover" })
|
||||
expect(takeover_update[:status]).to eq(200)
|
||||
end
|
||||
|
||||
it 'mirrors repeated playoff deletion and recreation into the follower tournament' do
|
||||
log_phase!('create small source and follower tournaments')
|
||||
source = create_group_stage_tournament(
|
||||
client: source_owner_client,
|
||||
public: false,
|
||||
name_prefix: 'Sync Playoff Delete Source',
|
||||
group_count: 2,
|
||||
teams_per_group: 2,
|
||||
playoff_teams_amount: 2
|
||||
)
|
||||
follower = create_follower_tournament(client: follower_owner_client, public: true, name_prefix: 'Sync Playoff Delete Follower')
|
||||
|
||||
configure_sync = source_owner_client.patch("/tournaments/#{source.fetch(:id)}", body: {
|
||||
sync_target_url: sync_state_url(follower.fetch(:id)),
|
||||
sync_auth_token: sync_token
|
||||
})
|
||||
expect(configure_sync[:status]).to eq(200)
|
||||
follower = wait_for_tournament_sync!(source_tournament_id: source.fetch(:id), follower_tournament_id: follower.fetch(:id))
|
||||
expect(tournament_signature(follower)).to eq(tournament_signature(source))
|
||||
|
||||
rounds = playoff_recreation_rounds
|
||||
rounds.each_with_index do |round, index|
|
||||
log_phase!("create playoffs round #{index + 1}")
|
||||
apply_group_score_round!(
|
||||
source_tournament_id: source.fetch(:id),
|
||||
scores_by_team_name: round.fetch(:scores_by_team_name)
|
||||
)
|
||||
source = fetch_tournament(client: source_anonymous_client, tournament_id: source.fetch(:id))
|
||||
group_stage = source.fetch(:stages).find { |stage| stage.fetch(:level) == -1 }
|
||||
finish_stage = source_owner_client.patch("/stages/#{group_stage.fetch(:id)}", body: { state: 'finished' })
|
||||
expect(finish_stage[:status]).to eq(200)
|
||||
|
||||
source = fetch_tournament(client: source_anonymous_client, tournament_id: source.fetch(:id))
|
||||
follower = wait_for_tournament_sync!(source_tournament_id: source.fetch(:id), follower_tournament_id: follower.fetch(:id))
|
||||
expect(playoff_matchups(source)).to eq(round.fetch(:expected_playoff_matchups))
|
||||
expect(playoff_matchups(follower)).to eq(round.fetch(:expected_playoff_matchups))
|
||||
expect(tournament_signature(follower)).to eq(tournament_signature(source))
|
||||
|
||||
next if index == rounds.size - 1
|
||||
|
||||
locked_delete = follower_owner_client.delete("/tournaments/#{follower.fetch(:id)}/playoffs")
|
||||
expect(locked_delete[:status]).to eq(423)
|
||||
expect(locked_delete.dig(:json, :error)).to eq('Tournament is in read only follower mode')
|
||||
|
||||
log_phase!("delete playoffs round #{index + 1}")
|
||||
delete_playoffs = source_owner_client.delete("/tournaments/#{source.fetch(:id)}/playoffs")
|
||||
expect(delete_playoffs[:status]).to eq(200)
|
||||
|
||||
source = fetch_tournament(client: source_anonymous_client, tournament_id: source.fetch(:id))
|
||||
follower = wait_for_tournament_sync!(source_tournament_id: source.fetch(:id), follower_tournament_id: follower.fetch(:id))
|
||||
expect(source.fetch(:stages).map { |stage| stage.fetch(:level) }).to eq([-1])
|
||||
expect(follower.fetch(:stages).map { |stage| stage.fetch(:level) }).to eq([-1])
|
||||
expect(source.fetch(:stages).first.fetch(:state)).to eq('in_progress')
|
||||
expect(follower.fetch(:stages).first.fetch(:state)).to eq('in_progress')
|
||||
expect(tournament_signature(follower)).to eq(tournament_signature(source))
|
||||
end
|
||||
end
|
||||
|
||||
def log_phase!(message)
|
||||
puts "phase #{message}"
|
||||
$stdout.flush
|
||||
end
|
||||
|
||||
def login_client(base_url:, email:, password:)
|
||||
client = TurniereE2E::ApiClient.new(base_url:)
|
||||
response = client.login!(email:, password:)
|
||||
client = TurniereE2E::ApiClient.new(base_url: base_url)
|
||||
response = client.login!(email: email, password: password)
|
||||
|
||||
expect(response[:status]).to eq(200)
|
||||
expect(client.authenticated?).to eq(true)
|
||||
client
|
||||
end
|
||||
|
||||
def create_group_stage_tournament(client:, public:, name_prefix:)
|
||||
def create_group_stage_tournament(client:, public:, name_prefix:, **options)
|
||||
group_count = options.fetch(:group_count, 4)
|
||||
teams_per_group = options.fetch(:teams_per_group, 4)
|
||||
playoff_teams_amount = options.fetch(:playoff_teams_amount, 8)
|
||||
payload = {
|
||||
name: "#{name_prefix} #{unique_suffix}",
|
||||
description: 'Follower sync source tournament',
|
||||
public:,
|
||||
public: public,
|
||||
location: "Source Venue #{name_prefix}",
|
||||
starts_at: '2030-06-01T10:00:00+02:00',
|
||||
ends_at: '2030-06-01T18:00:00+02:00',
|
||||
website_url: 'https://example.com/sync-source',
|
||||
group_stage: true,
|
||||
playoff_teams_amount: 8,
|
||||
teams: 4.times.flat_map do |group_index|
|
||||
Array.new(4) do |seed_index|
|
||||
playoff_teams_amount: playoff_teams_amount,
|
||||
teams: group_count.times.flat_map do |group_index|
|
||||
teams_per_group.times.map do |seed_index|
|
||||
{
|
||||
name: "#{name_prefix.tr(' ', '')}-G#{group_index + 1}-S#{seed_index + 1}",
|
||||
group: group_index
|
||||
|
|
@ -270,34 +375,46 @@ RSpec.describe 'Tournament follower sync HTTP E2E' do
|
|||
|
||||
response = client.post('/tournaments', body: payload)
|
||||
expect(response[:status]).to eq(201)
|
||||
fetch_tournament(client:, tournament_id: response.dig(:json, :id))
|
||||
fetch_tournament(client: client, tournament_id: response.dig(:json, :id))
|
||||
end
|
||||
|
||||
def sync_state_url(follower_id)
|
||||
"#{follower_base_url}/tournaments/#{follower_id}/sync_state"
|
||||
end
|
||||
|
||||
def create_playoff_tournament(client:, public:, name_prefix:)
|
||||
payload = {
|
||||
name: "#{name_prefix} #{unique_suffix}",
|
||||
description: 'Unrelated playoff tournament',
|
||||
public:,
|
||||
teams: Array.new(4) do |index|
|
||||
public: public,
|
||||
location: "Noise Venue #{name_prefix}",
|
||||
starts_at: '2030-06-01T10:00:00+02:00',
|
||||
ends_at: '2030-06-01T18:00:00+02:00',
|
||||
website_url: 'https://example.com/noise',
|
||||
teams: 4.times.map do |index|
|
||||
{ name: "#{name_prefix.tr(' ', '')}-S#{index + 1}" }
|
||||
end
|
||||
}
|
||||
|
||||
response = client.post('/tournaments', body: payload)
|
||||
expect(response[:status]).to eq(201)
|
||||
fetch_tournament(client:, tournament_id: response.dig(:json, :id))
|
||||
fetch_tournament(client: client, tournament_id: response.dig(:json, :id))
|
||||
end
|
||||
|
||||
def create_follower_tournament(client:, public:, name_prefix:)
|
||||
response = client.post('/tournaments', body: {
|
||||
name: "#{name_prefix} #{unique_suffix}",
|
||||
description: 'Follower tournament',
|
||||
public:,
|
||||
public: public,
|
||||
location: "Follower Venue #{name_prefix}",
|
||||
starts_at: '2030-05-01T10:00:00+02:00',
|
||||
ends_at: '2030-05-01T18:00:00+02:00',
|
||||
website_url: 'https://example.com/follower',
|
||||
read_only_mode: true,
|
||||
sync_auth_token: sync_token
|
||||
})
|
||||
expect(response[:status]).to eq(201)
|
||||
fetch_tournament(client:, tournament_id: response.dig(:json, :id))
|
||||
fetch_tournament(client: client, tournament_id: response.dig(:json, :id))
|
||||
end
|
||||
|
||||
def fetch_tournament(client:, tournament_id:)
|
||||
|
|
@ -321,9 +438,9 @@ RSpec.describe 'Tournament follower sync HTTP E2E' do
|
|||
amount = rand(range)
|
||||
Array.new(amount) do |index|
|
||||
if index.even?
|
||||
create_playoff_tournament(client:, public: false, name_prefix: "#{prefix} Playoff #{index}")
|
||||
create_playoff_tournament(client: client, public: false, name_prefix: "#{prefix} Playoff #{index}")
|
||||
else
|
||||
create_group_stage_tournament(client:, public: true, name_prefix: "#{prefix} Groups #{index}")
|
||||
create_group_stage_tournament(client: client, public: true, name_prefix: "#{prefix} Groups #{index}")
|
||||
end
|
||||
end
|
||||
end
|
||||
|
|
@ -339,7 +456,7 @@ RSpec.describe 'Tournament follower sync HTTP E2E' do
|
|||
update = client.patch("/tournaments/#{tournament.fetch(:id)}", body: { name: updated_name })
|
||||
expect(update[:status]).to eq(200)
|
||||
|
||||
refreshed = fetch_tournament(client:, tournament_id: tournament.fetch(:id))
|
||||
refreshed = fetch_tournament(client: client, tournament_id: tournament.fetch(:id))
|
||||
team = refreshed.fetch(:teams).first
|
||||
return if team.nil?
|
||||
|
||||
|
|
@ -363,21 +480,21 @@ RSpec.describe 'Tournament follower sync HTTP E2E' do
|
|||
end
|
||||
|
||||
def wait_for_tournament_sync!(source_tournament_id:, follower_tournament_id:, timeout: 20)
|
||||
deadline = Time.zone.now + timeout
|
||||
last_progress_at = Time.zone.now
|
||||
deadline = Time.now + timeout
|
||||
last_progress_at = Time.now
|
||||
|
||||
loop do
|
||||
source = fetch_tournament(client: source_anonymous_client, tournament_id: source_tournament_id)
|
||||
follower = fetch_tournament(client: follower_anonymous_client, tournament_id: follower_tournament_id)
|
||||
return follower if tournament_signature(source) == tournament_signature(follower)
|
||||
|
||||
if Time.zone.now - last_progress_at >= 2
|
||||
if Time.now - last_progress_at >= 2
|
||||
puts "sync wait source=#{sync_progress_signature(source).inspect} follower=#{sync_progress_signature(follower).inspect}"
|
||||
$stdout.flush
|
||||
last_progress_at = Time.zone.now
|
||||
last_progress_at = Time.now
|
||||
end
|
||||
|
||||
if Time.zone.now >= deadline
|
||||
if Time.now >= deadline
|
||||
raise <<~ERROR
|
||||
Follower did not catch up within #{timeout}s
|
||||
source_signature=#{tournament_signature(source).inspect}
|
||||
|
|
@ -397,8 +514,8 @@ RSpec.describe 'Tournament follower sync HTTP E2E' do
|
|||
end
|
||||
|
||||
def synced_team_id_pairs(source:, follower:)
|
||||
source_by_name = source.fetch(:teams).index_by do |team|
|
||||
team.fetch(:name)
|
||||
source_by_name = source.fetch(:teams).each_with_object({}) do |team, teams_by_name|
|
||||
teams_by_name[team.fetch(:name)] = team
|
||||
end
|
||||
follower.fetch(:teams).filter_map do |team|
|
||||
source_team = source_by_name[team.fetch(:name)]
|
||||
|
|
@ -423,8 +540,7 @@ RSpec.describe 'Tournament follower sync HTTP E2E' do
|
|||
|
||||
stage_finish = source_owner_client.patch("/stages/#{group_stage.fetch(:id)}", body: { state: 'finished' })
|
||||
expect(stage_finish[:status]).to eq(422)
|
||||
expect(stage_finish.dig(:json,
|
||||
:error)).to eq('Playoffs cannot be generated yet because a decider match is required for an advancing place')
|
||||
expect(stage_finish.dig(:json, :error)).to eq('Playoffs cannot be generated yet because a decider match is required for an advancing place')
|
||||
expect(stage_finish.dig(:json, :blocking_ties).size).to be >= 2
|
||||
|
||||
stage_finish.fetch(:json).fetch(:blocking_ties).each_with_index do |blocking_tie, index|
|
||||
|
|
@ -446,8 +562,7 @@ RSpec.describe 'Tournament follower sync HTTP E2E' do
|
|||
expect(finish_decider[:status]).to eq(200)
|
||||
end
|
||||
|
||||
finish_stage_after_decider = source_owner_client.patch("/stages/#{group_stage.fetch(:id)}",
|
||||
body: { state: 'finished' })
|
||||
finish_stage_after_decider = source_owner_client.patch("/stages/#{group_stage.fetch(:id)}", body: { state: 'finished' })
|
||||
expect(finish_stage_after_decider[:status]).to eq(200)
|
||||
end
|
||||
|
||||
|
|
@ -485,16 +600,104 @@ RSpec.describe 'Tournament follower sync HTTP E2E' do
|
|||
play_match!(match_id: match.fetch(:id), first_score: score_a, second_score: score_b)
|
||||
end
|
||||
|
||||
def playoff_recreation_rounds
|
||||
[
|
||||
{
|
||||
scores_by_team_name: {
|
||||
'G1-S1' => 3,
|
||||
'G1-S2' => 1,
|
||||
'G2-S1' => 3,
|
||||
'G2-S2' => 1
|
||||
},
|
||||
expected_playoff_matchups: [
|
||||
%w[G1-S1 G2-S2],
|
||||
%w[G1-S2 G2-S1]
|
||||
]
|
||||
},
|
||||
{
|
||||
scores_by_team_name: {
|
||||
'G1-S1' => 0,
|
||||
'G1-S2' => 4,
|
||||
'G2-S1' => 0,
|
||||
'G2-S2' => 4
|
||||
},
|
||||
expected_playoff_matchups: [
|
||||
%w[G1-S2 G2-S1],
|
||||
%w[G1-S1 G2-S2]
|
||||
]
|
||||
},
|
||||
{
|
||||
scores_by_team_name: {
|
||||
'G1-S1' => 5,
|
||||
'G1-S2' => 2,
|
||||
'G2-S1' => 2,
|
||||
'G2-S2' => 5
|
||||
},
|
||||
expected_playoff_matchups: [
|
||||
%w[G1-S1 G2-S1],
|
||||
%w[G1-S2 G2-S2]
|
||||
]
|
||||
}
|
||||
]
|
||||
end
|
||||
|
||||
def apply_group_score_round!(source_tournament_id:, scores_by_team_name:)
|
||||
tournament = fetch_tournament(client: source_anonymous_client, tournament_id: source_tournament_id)
|
||||
group_stage = tournament.fetch(:stages).find { |stage| stage.fetch(:level) == -1 }
|
||||
|
||||
group_stage.fetch(:groups).each do |group|
|
||||
group.fetch(:matches).each do |match|
|
||||
if match.fetch(:state) == 'not_started'
|
||||
start_response = source_owner_client.patch("/matches/#{match.fetch(:id)}", body: { state: 'in_progress' })
|
||||
expect(start_response[:status]).to eq(200)
|
||||
end
|
||||
|
||||
update_match_points_by_seed!(match.fetch(:id), scores_by_team_name)
|
||||
|
||||
next if match.fetch(:state) == 'finished'
|
||||
|
||||
finish_response = source_owner_client.patch("/matches/#{match.fetch(:id)}", body: { state: 'finished' })
|
||||
expect(finish_response[:status]).to eq(200)
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
def update_match_points_by_seed!(match_id, scores_by_team_name)
|
||||
match = source_anonymous_client.get("/matches/#{match_id}")
|
||||
expect(match[:status]).to eq(200)
|
||||
|
||||
match.fetch(:json).fetch(:match_scores).each do |score|
|
||||
response = source_owner_client.patch("/match_scores/#{score.fetch(:id)}", body: {
|
||||
points: scores_by_team_name.fetch(seed_label(score.dig(:team, :name)))
|
||||
})
|
||||
expect(response[:status]).to eq(200)
|
||||
end
|
||||
end
|
||||
|
||||
def playoff_matchups(tournament)
|
||||
entry_stage = tournament.fetch(:stages)
|
||||
.select { |stage| stage.fetch(:level) >= 0 }
|
||||
.max_by { |stage| stage.fetch(:level) }
|
||||
entry_stage.fetch(:matches)
|
||||
.sort_by { |match| match.fetch(:position) }
|
||||
.map do |match|
|
||||
match.fetch(:match_scores)
|
||||
.map { |score| seed_label(score.dig(:team, :name)) }
|
||||
.sort
|
||||
end
|
||||
end
|
||||
|
||||
def seed_label(team_name)
|
||||
team_name[/G\d+-S\d+\z/] || team_name
|
||||
end
|
||||
|
||||
def finish_playoff_bracket!(source_id:)
|
||||
loop do
|
||||
tournament = fetch_tournament(client: source_anonymous_client, tournament_id: source_id)
|
||||
playoff_matches = tournament.fetch(:stages)
|
||||
.select { |stage| stage.fetch(:level) >= 0 }
|
||||
.flat_map { |stage| stage.fetch(:matches) }
|
||||
.select do |match|
|
||||
%w[not_started
|
||||
in_progress].include?(match.fetch(:state)) && match.fetch(:match_scores).size == 2
|
||||
end
|
||||
.select { |match| %w[not_started in_progress].include?(match.fetch(:state)) && match.fetch(:match_scores).size == 2 }
|
||||
.sort_by { |match| [match.dig(:stage, :id) || 0, match.fetch(:position)] }
|
||||
break if playoff_matches.empty?
|
||||
|
||||
|
|
@ -516,8 +719,7 @@ RSpec.describe 'Tournament follower sync HTTP E2E' do
|
|||
|
||||
ordered_scores = match.fetch(:json).fetch(:match_scores).sort_by { |score| score.fetch(:id) }
|
||||
[first_score, second_score].each_with_index do |points, index|
|
||||
response = source_owner_client.patch("/match_scores/#{ordered_scores[index].fetch(:id)}",
|
||||
body: { points: })
|
||||
response = source_owner_client.patch("/match_scores/#{ordered_scores[index].fetch(:id)}", body: { points: points })
|
||||
expect(response[:status]).to eq(200)
|
||||
end
|
||||
|
||||
|
|
@ -529,13 +731,18 @@ RSpec.describe 'Tournament follower sync HTTP E2E' do
|
|||
{
|
||||
name: tournament.fetch(:name),
|
||||
public: tournament.fetch(:public),
|
||||
location: tournament[:location],
|
||||
starts_at: tournament[:starts_at],
|
||||
ends_at: tournament[:ends_at],
|
||||
website_url: tournament[:website_url],
|
||||
playoff_teams_amount: tournament.fetch(:playoff_teams_amount),
|
||||
instant_finalists_amount: tournament.fetch(:instant_finalists_amount),
|
||||
intermediate_round_participants_amount: tournament.fetch(:intermediate_round_participants_amount),
|
||||
timestamp: tournament[:timestamp],
|
||||
teams: tournament.fetch(:teams).map do |team|
|
||||
team.slice(:name, :advancing_from_group_stage)
|
||||
end.sort_by { |team| team.fetch(:name) },
|
||||
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) },
|
||||
team_action_lists: tournament.fetch(:team_action_lists, []).sort_by { |list| list.fetch(:key) }.map do |list|
|
||||
{
|
||||
key: list.fetch(:key),
|
||||
|
|
@ -576,6 +783,9 @@ RSpec.describe 'Tournament follower sync HTTP E2E' do
|
|||
state: match.fetch(:state),
|
||||
position: match.fetch(:position),
|
||||
decider: match.fetch(:decider),
|
||||
location: match[:location],
|
||||
start_time: match[:start_time],
|
||||
notes: match[:notes],
|
||||
scores: match.fetch(:match_scores).map do |score|
|
||||
{
|
||||
team_name: score.dig(:team, :name),
|
||||
|
|
@ -627,6 +837,6 @@ RSpec.describe 'Tournament follower sync HTTP E2E' do
|
|||
end
|
||||
|
||||
def playoff_token_item_completed?(tournament, team_name:)
|
||||
find_team_action_item(tournament, team_name:).fetch(:completed)
|
||||
find_team_action_item(tournament, team_name: team_name).fetch(:completed)
|
||||
end
|
||||
end
|
||||
|
|
|
|||
|
|
@ -27,8 +27,7 @@ RSpec.describe 'Tournament follower sync websocket HTTP E2E' do
|
|||
let(:follower_anonymous_client) { TurniereE2E::ApiClient.new(base_url: follower_base_url) }
|
||||
|
||||
it 'broadcasts follower websocket updates after source sync import' do
|
||||
source = create_group_stage_tournament(client: source_client, base_url: source_base_url,
|
||||
name_prefix: 'Ws Sync Source')
|
||||
source = create_group_stage_tournament(client: source_client, base_url: source_base_url, name_prefix: 'Ws Sync Source')
|
||||
follower = create_follower_tournament(name_prefix: 'Ws Sync Follower')
|
||||
|
||||
configure_sync = source_client.patch("/tournaments/#{source.fetch(:id)}", body: {
|
||||
|
|
@ -62,7 +61,7 @@ RSpec.describe 'Tournament follower sync websocket HTTP E2E' do
|
|||
end
|
||||
|
||||
def login_client(base_url:)
|
||||
client = TurniereE2E::ApiClient.new(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
|
||||
|
|
@ -76,7 +75,7 @@ RSpec.describe 'Tournament follower sync websocket HTTP E2E' do
|
|||
group_stage: true,
|
||||
playoff_teams_amount: 4,
|
||||
teams: 2.times.flat_map do |group_index|
|
||||
Array.new(4) do |seed_index|
|
||||
4.times.map do |seed_index|
|
||||
{
|
||||
name: "#{name_prefix.tr(' ', '')}-G#{group_index + 1}-S#{seed_index + 1}",
|
||||
group: group_index
|
||||
|
|
@ -85,7 +84,7 @@ RSpec.describe 'Tournament follower sync websocket HTTP E2E' do
|
|||
end
|
||||
})
|
||||
expect(response[:status]).to eq(201)
|
||||
fetch_tournament(base_url:, tournament_id: response.dig(:json, :id))
|
||||
fetch_tournament(base_url: base_url, tournament_id: response.dig(:json, :id))
|
||||
end
|
||||
|
||||
def create_follower_tournament(name_prefix:)
|
||||
|
|
@ -102,7 +101,7 @@ RSpec.describe 'Tournament follower sync websocket HTTP E2E' do
|
|||
end
|
||||
|
||||
def fetch_tournament(base_url:, tournament_id:)
|
||||
client = TurniereE2E::ApiClient.new(base_url:)
|
||||
client = TurniereE2E::ApiClient.new(base_url: base_url)
|
||||
response = client.get("/tournaments/#{tournament_id}")
|
||||
expect(response[:status]).to eq(200)
|
||||
response.fetch(:json)
|
||||
|
|
@ -142,7 +141,7 @@ RSpec.describe 'Tournament follower sync websocket HTTP E2E' do
|
|||
|
||||
ordered_scores = match_response.fetch(:json).fetch(:match_scores).sort_by { |score| score.fetch(:id) }
|
||||
[first_score, second_score].each_with_index do |points, index|
|
||||
score_update = source_client.patch("/match_scores/#{ordered_scores[index].fetch(:id)}", body: { points: })
|
||||
score_update = source_client.patch("/match_scores/#{ordered_scores[index].fetch(:id)}", body: { points: points })
|
||||
expect(score_update[:status]).to eq(200)
|
||||
end
|
||||
|
||||
|
|
@ -151,24 +150,24 @@ RSpec.describe 'Tournament follower sync websocket HTTP E2E' do
|
|||
end
|
||||
|
||||
def wait_for_list_presence!(tournament_id, timeout: 20)
|
||||
deadline = Time.zone.now + timeout
|
||||
deadline = Time.now + timeout
|
||||
loop do
|
||||
follower = fetch_tournament(base_url: follower_base_url, tournament_id:)
|
||||
follower = fetch_tournament(base_url: follower_base_url, tournament_id: tournament_id)
|
||||
return follower if follower.fetch(:team_action_lists, []).any?
|
||||
|
||||
raise "follower list not present within #{timeout}s" if Time.zone.now >= deadline
|
||||
raise "follower list not present within #{timeout}s" if Time.now >= deadline
|
||||
|
||||
sleep 0.5
|
||||
end
|
||||
end
|
||||
|
||||
def wait_for_team_action_list_state!(cable_client, team_name:, completed:, timeout: 20)
|
||||
deadline = Time.zone.now + timeout
|
||||
deadline = Time.now + timeout
|
||||
loop do
|
||||
payload = cable_client.wait_for_payload!(timeout: [deadline - Time.zone.now, 0.1].max)
|
||||
return payload if team_action_item_completed?(payload, team_name:) == completed
|
||||
payload = cable_client.wait_for_payload!(timeout: [deadline - Time.now, 0.1].max)
|
||||
return payload if team_action_item_completed?(payload, team_name: team_name) == completed
|
||||
|
||||
raise "follower websocket payload not received within #{timeout}s" if Time.zone.now >= deadline
|
||||
raise "follower websocket payload not received within #{timeout}s" if Time.now >= deadline
|
||||
end
|
||||
end
|
||||
|
||||
|
|
|
|||
|
|
@ -14,10 +14,10 @@ RSpec.describe 'Tournament lifecycle HTTP E2E' do
|
|||
let(:base_url) { ENV.fetch('TURNIERE_E2E_BASE_URL', 'http://127.0.0.1:3000') }
|
||||
let(:runner) do
|
||||
TurniereE2E::ScenarioRunner.new(
|
||||
base_url:,
|
||||
base_url: base_url,
|
||||
email: ENV.fetch('TURNIERE_E2E_EMAIL'),
|
||||
password: ENV.fetch('TURNIERE_E2E_PASSWORD'),
|
||||
username: ENV.fetch('TURNIERE_E2E_USERNAME', nil)
|
||||
username: ENV['TURNIERE_E2E_USERNAME']
|
||||
)
|
||||
end
|
||||
|
||||
|
|
@ -31,7 +31,7 @@ RSpec.describe 'Tournament lifecycle HTTP E2E' do
|
|||
expect(result.dig(:checkpoints, :statistics, :group_scores_count)).to eq(16)
|
||||
|
||||
playoffs_created = result.dig(:checkpoints, :playoffs_created, :stage_summaries)
|
||||
playoff_entry_stage = playoffs_created.reject { |stage| stage[:level] == -1 }.max_by { |stage| stage[:level] }
|
||||
playoff_entry_stage = playoffs_created.select { |stage| stage[:level] != -1 }.max_by { |stage| stage[:level] }
|
||||
expect(playoff_entry_stage[:match_states].count).to eq(4)
|
||||
|
||||
final_attempt = result.dig(:checkpoints, :final_finish_attempted, :finish_attempt)
|
||||
|
|
@ -49,7 +49,7 @@ RSpec.describe 'Tournament lifecycle HTTP E2E' do
|
|||
expect(created[:intermediate_round_participants_amount]).to eq(2)
|
||||
|
||||
playoff_entry_stage = result.dig(:checkpoints, :playoffs_created, :stage_summaries)
|
||||
.reject { |stage| stage[:level] == -1 }
|
||||
.select { |stage| stage[:level] != -1 }
|
||||
.max_by { |stage| stage[:level] }
|
||||
expect(playoff_entry_stage[:state]).to eq('intermediate_stage')
|
||||
expect(playoff_entry_stage[:match_states].count('single_team')).to eq(3)
|
||||
|
|
@ -61,6 +61,27 @@ RSpec.describe 'Tournament lifecycle HTTP E2E' do
|
|||
.find { |stage| stage[:level] == 0 }[:match_states]).to eq(%w[finished finished])
|
||||
end
|
||||
|
||||
it 'deletes and recreates group-stage playoffs over HTTP with changed group results' do
|
||||
result = runner.run_group_stage_playoff_recreation
|
||||
checkpoints = result.fetch(:checkpoints)
|
||||
|
||||
expect(checkpoints.fetch(:first_playoffs_created).fetch(:playoff_matchups)).to eq([
|
||||
%w[G1-S1 G2-S2],
|
||||
%w[G1-S2 G2-S1]
|
||||
])
|
||||
expect(checkpoints.fetch(:first_playoffs_deleted).fetch(:stage_summaries).map { |stage| stage.fetch(:level) }).to eq([-1])
|
||||
expect(checkpoints.fetch(:first_playoffs_deleted).fetch(:stage_summaries).first.fetch(:state)).to eq('in_progress')
|
||||
expect(checkpoints.fetch(:second_playoffs_created).fetch(:playoff_matchups)).to eq([
|
||||
%w[G1-S2 G2-S1],
|
||||
%w[G1-S1 G2-S2]
|
||||
])
|
||||
expect(checkpoints.fetch(:second_playoffs_deleted).fetch(:stage_summaries).map { |stage| stage.fetch(:level) }).to eq([-1])
|
||||
expect(checkpoints.fetch(:third_playoffs_created).fetch(:playoff_matchups)).to eq([
|
||||
%w[G1-S1 G2-S1],
|
||||
%w[G1-S2 G2-S2]
|
||||
])
|
||||
end
|
||||
|
||||
{
|
||||
won: [1, 2],
|
||||
lost: [2, 1],
|
||||
|
|
@ -76,8 +97,7 @@ RSpec.describe 'Tournament lifecycle HTTP E2E' do
|
|||
expect(positions.fetch('G1-S2')).to eq(expected_positions[1])
|
||||
|
||||
if direct_result == :drawn
|
||||
expect(ordered_scores[0].slice(:team_name, :position, :group_points, :difference_in_points, :scored_points,
|
||||
:received_points))
|
||||
expect(ordered_scores[0].slice(:team_name, :position, :group_points, :difference_in_points, :scored_points, :received_points))
|
||||
.to eq(
|
||||
team_name: 'G1-S1',
|
||||
position: 1,
|
||||
|
|
@ -86,8 +106,7 @@ RSpec.describe 'Tournament lifecycle HTTP E2E' do
|
|||
scored_points: 4,
|
||||
received_points: 1
|
||||
)
|
||||
expect(ordered_scores[1].slice(:team_name, :position, :group_points, :difference_in_points, :scored_points,
|
||||
:received_points))
|
||||
expect(ordered_scores[1].slice(:team_name, :position, :group_points, :difference_in_points, :scored_points, :received_points))
|
||||
.to eq(
|
||||
team_name: 'G1-S2',
|
||||
position: 1,
|
||||
|
|
@ -98,10 +117,9 @@ RSpec.describe 'Tournament lifecycle HTTP E2E' do
|
|||
)
|
||||
else
|
||||
expected_first_group_points = direct_result == :lost ? 9 : 6
|
||||
expected_second_group_points = 6
|
||||
expected_second_group_points = direct_result == :lost ? 6 : 6
|
||||
|
||||
expect(ordered_scores[0].slice(:team_name, :position, :group_points, :difference_in_points, :scored_points,
|
||||
:received_points))
|
||||
expect(ordered_scores[0].slice(:team_name, :position, :group_points, :difference_in_points, :scored_points, :received_points))
|
||||
.to eq(
|
||||
team_name: expected_positions[0] == 1 ? 'G1-S1' : 'G1-S2',
|
||||
position: 1,
|
||||
|
|
@ -110,8 +128,7 @@ RSpec.describe 'Tournament lifecycle HTTP E2E' do
|
|||
scored_points: 5,
|
||||
received_points: 2
|
||||
)
|
||||
expect(ordered_scores[1].slice(:team_name, :position, :group_points, :difference_in_points, :scored_points,
|
||||
:received_points))
|
||||
expect(ordered_scores[1].slice(:team_name, :position, :group_points, :difference_in_points, :scored_points, :received_points))
|
||||
.to eq(
|
||||
team_name: expected_positions[0] == 1 ? 'G1-S2' : 'G1-S1',
|
||||
position: 2,
|
||||
|
|
@ -126,11 +143,28 @@ RSpec.describe 'Tournament lifecycle HTTP E2E' do
|
|||
|
||||
[2, 3, 5, 8].each do |team_count|
|
||||
it "drives a #{team_count}-team playoff-only tournament over HTTP to completion" do
|
||||
result = runner.run_playoff_only(team_count:)
|
||||
result = runner.run_playoff_only(team_count: team_count)
|
||||
|
||||
completed_stages = result.dig(:checkpoints, :completed, :stage_summaries)
|
||||
final_stage = completed_stages.find { |stage| stage[:level] == 0 }
|
||||
expect(final_stage[:match_states]).to eq(team_count > 3 ? %w[finished finished] : ['finished'])
|
||||
end
|
||||
end
|
||||
|
||||
it 'records ordered owner-readable transaction history over HTTP' do
|
||||
result = runner.run_transaction_log_history
|
||||
log = result.dig(:checkpoints, :transaction_log)
|
||||
|
||||
expect(log.fetch(:sequences)).to eq((1..log.fetch(:sequences).count).to_a)
|
||||
expect(log.fetch(:actions)).to include(
|
||||
'tournament.created',
|
||||
'match.state_changed',
|
||||
'match_score.updated',
|
||||
'playoffs.start_failed',
|
||||
'decider_match.created',
|
||||
'playoffs.started'
|
||||
)
|
||||
expect(log.fetch(:messages).join("\n")).to include('failed because decider missing')
|
||||
expect(log.fetch(:messages).join("\n")).to include('started playoffs, advancing teams:')
|
||||
end
|
||||
end
|
||||
|
|
|
|||
|
|
@ -15,20 +15,17 @@ RSpec.describe 'Tournament rendering HTTP E2E' do
|
|||
let(:expect_profiling) { ENV.fetch('TURNIERE_E2E_EXPECT_PROFILING', 'true') == 'true' }
|
||||
let(:runner) do
|
||||
TurniereE2E::ScenarioRunner.new(
|
||||
base_url:,
|
||||
base_url: base_url,
|
||||
email: ENV.fetch('TURNIERE_E2E_EMAIL'),
|
||||
password: ENV.fetch('TURNIERE_E2E_PASSWORD'),
|
||||
username: ENV.fetch('TURNIERE_E2E_USERNAME', nil)
|
||||
username: ENV['TURNIERE_E2E_USERNAME']
|
||||
)
|
||||
end
|
||||
|
||||
[
|
||||
{ group_count: 8, teams_per_group: 4, playoff_teams_amount: 8, max_request_duration_ms: 1000,
|
||||
max_server_duration_ms: 1000 },
|
||||
{ group_count: 64, teams_per_group: 6, playoff_teams_amount: 64, max_request_duration_ms: 5000,
|
||||
max_server_duration_ms: 5000 },
|
||||
{ group_count: 4, teams_per_group: 32, playoff_teams_amount: 4, max_request_duration_ms: 5000,
|
||||
max_server_duration_ms: 5000 }
|
||||
{ group_count: 8, teams_per_group: 4, playoff_teams_amount: 8, max_request_duration_ms: 1000, max_server_duration_ms: 1000 },
|
||||
{ group_count: 64, teams_per_group: 6, playoff_teams_amount: 64, max_request_duration_ms: 5000, max_server_duration_ms: 5000 },
|
||||
{ group_count: 4, teams_per_group: 32, playoff_teams_amount: 4, max_request_duration_ms: 5000, max_server_duration_ms: 5000 }
|
||||
].each do |scenario|
|
||||
it "creates #{scenario[:group_count]}x#{scenario[:teams_per_group]} via API and renders within budget" do
|
||||
result = runner.run_group_stage_render_profile(
|
||||
|
|
|
|||
Some files were not shown because too many files have changed in this diff Show More
Loading…
Reference in New Issue