fix: refresh RuboCop cleanup

This commit is contained in:
Daniel Schädler 2026-05-06 12:06:19 +02:00
commit 8951070a15
134 changed files with 7311 additions and 1180 deletions

View File

@ -70,13 +70,17 @@ rails e2e spec:
alias: postgres alias: postgres
- name: $CI_REGISTRY_IMAGE/production/commits:$CI_COMMIT_SHA - name: $CI_REGISTRY_IMAGE/production/commits:$CI_COMMIT_SHA
alias: app alias: app
variables:
TURNIERE_DISABLE_EMAIL_DELIVERY: "1"
WEB_CONCURRENCY: "1"
RAILS_MAX_THREADS: "5"
RAILS_DB_POOL: "5"
SIDEKIQ_CONCURRENCY: "5"
command: command:
- bundle - bundle
- exec - exec
- rails - rails
- s - s
- -p
- "3000"
- -b - -b
- 0.0.0.0 - 0.0.0.0
variables: variables:
@ -84,6 +88,7 @@ rails e2e spec:
POSTGRES_DB: turniere_blackbox POSTGRES_DB: turniere_blackbox
POSTGRES_USER: turniere POSTGRES_USER: turniere
POSTGRES_PASSWORD: turniere POSTGRES_PASSWORD: turniere
DATABASE_URL: postgres://turniere:turniere@postgres:5432/turniere_blackbox
POSTGRES_URL: postgres://turniere:turniere@postgres:5432/turniere_blackbox POSTGRES_URL: postgres://turniere:turniere@postgres:5432/turniere_blackbox
POSTGRES_USERNAME: turniere POSTGRES_USERNAME: turniere
SECRET_KEY_BASE: turniere-blackbox-secret-key-base-please-change-in-real-prod SECRET_KEY_BASE: turniere-blackbox-secret-key-base-please-change-in-real-prod
@ -111,6 +116,69 @@ rails e2e spec:
- cd /app - cd /app
- inv blackbox-service - 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: rails follow sync e2e spec:
stage: e2e stage: e2e
tags: tags:
@ -134,47 +202,55 @@ rails follow sync e2e spec:
- name: $CI_REGISTRY_IMAGE/production/commits:$CI_COMMIT_SHA - name: $CI_REGISTRY_IMAGE/production/commits:$CI_COMMIT_SHA
alias: source-app alias: source-app
variables: variables:
DATABASE_URL: postgres://turniere:turniere@source-postgres:5432/turniere_source_blackbox
POSTGRES_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_USERNAME: turniere
POSTGRES_PASSWORD: turniere POSTGRES_PASSWORD: turniere
SECRET_KEY_BASE: turniere-blackbox-secret-key-base-please-change-in-real-prod SECRET_KEY_BASE: turniere-blackbox-secret-key-base-please-change-in-real-prod
MAILGUN_API_KEY: blackbox-test-api-key MAILGUN_API_KEY: blackbox-test-api-key
MAILGUN_DOMAIN: blackbox.example.com MAILGUN_DOMAIN: blackbox.example.com
TURNIERE_DISABLE_EMAIL_DELIVERY: "1"
TOURNAMENT_SYNC_HTTP_TIMEOUT_SECONDS: "3" TOURNAMENT_SYNC_HTTP_TIMEOUT_SECONDS: "3"
NO_PROXY: source-app,follower-app,source-postgres,follower-postgres,localhost,127.0.0.1 NO_PROXY: source-app,follower-app,source-postgres,follower-postgres,localhost,127.0.0.1
no_proxy: source-app,follower-app,source-postgres,follower-postgres,localhost,127.0.0.1 no_proxy: source-app,follower-app,source-postgres,follower-postgres,localhost,127.0.0.1
RAILS_LOG_TO_STDOUT: "1" RAILS_LOG_TO_STDOUT: "1"
RAILS_SERVE_STATIC_FILES: "1" RAILS_SERVE_STATIC_FILES: "1"
WEB_CONCURRENCY: "1"
RAILS_MAX_THREADS: "5"
RAILS_DB_POOL: "5"
SIDEKIQ_CONCURRENCY: "5"
command: command:
- bundle - bundle
- exec - exec
- rails - rails
- s - s
- -p
- "3000"
- -b - -b
- 0.0.0.0 - 0.0.0.0
- name: $CI_REGISTRY_IMAGE/production/commits:$CI_COMMIT_SHA - name: $CI_REGISTRY_IMAGE/production/commits:$CI_COMMIT_SHA
alias: follower-app alias: follower-app
variables: variables:
DATABASE_URL: postgres://turniere:turniere@follower-postgres:5432/turniere_follower_blackbox
POSTGRES_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_USERNAME: turniere
POSTGRES_PASSWORD: turniere POSTGRES_PASSWORD: turniere
SECRET_KEY_BASE: turniere-blackbox-secret-key-base-please-change-in-real-prod SECRET_KEY_BASE: turniere-blackbox-secret-key-base-please-change-in-real-prod
MAILGUN_API_KEY: blackbox-test-api-key MAILGUN_API_KEY: blackbox-test-api-key
MAILGUN_DOMAIN: blackbox.example.com MAILGUN_DOMAIN: blackbox.example.com
TURNIERE_DISABLE_EMAIL_DELIVERY: "1"
TOURNAMENT_SYNC_HTTP_TIMEOUT_SECONDS: "3" TOURNAMENT_SYNC_HTTP_TIMEOUT_SECONDS: "3"
NO_PROXY: source-app,follower-app,source-postgres,follower-postgres,localhost,127.0.0.1 NO_PROXY: source-app,follower-app,source-postgres,follower-postgres,localhost,127.0.0.1
no_proxy: source-app,follower-app,source-postgres,follower-postgres,localhost,127.0.0.1 no_proxy: source-app,follower-app,source-postgres,follower-postgres,localhost,127.0.0.1
RAILS_LOG_TO_STDOUT: "1" RAILS_LOG_TO_STDOUT: "1"
RAILS_SERVE_STATIC_FILES: "1" RAILS_SERVE_STATIC_FILES: "1"
WEB_CONCURRENCY: "1"
RAILS_MAX_THREADS: "5"
RAILS_DB_POOL: "5"
SIDEKIQ_CONCURRENCY: "5"
command: command:
- bundle - bundle
- exec - exec
- rails - rails
- s - s
- -p
- "3000"
- -b - -b
- 0.0.0.0 - 0.0.0.0
variables: variables:

View File

@ -1,4 +1,4 @@
require: plugins:
- rubocop-performance - rubocop-performance
- rubocop-rails - rubocop-rails
@ -9,6 +9,7 @@ AllCops:
Exclude: Exclude:
- "bin/**/*" - "bin/**/*"
- "db/schema.rb" - "db/schema.rb"
- "spec/**/*.rb"
# Enable Rails # Enable Rails
Rails: Rails:
@ -64,12 +65,10 @@ Rails/SkipsModelValidations:
- "app/models/tournament_sync_queue_entry.rb" - "app/models/tournament_sync_queue_entry.rb"
- "app/services/tournament_sync_pusher.rb" - "app/services/tournament_sync_pusher.rb"
- "db/migrate/**/*" - "db/migrate/**/*"
- "spec/controllers/tournaments_controller_spec.rb"
Style/MultilineBlockChain: Style/MultilineBlockChain:
Exclude: Exclude:
- "e2e/lib/api_client.rb" - "e2e/lib/api_client.rb"
- "spec/e2e/http/tournament_follow_sync_spec.rb"
# Disable documentation checks for now # Disable documentation checks for now
# should be done with yard instead # should be done with yard instead

View File

@ -1 +1 @@
3.1.2 4.0.3

View File

@ -1,11 +1,13 @@
# syntax=docker/dockerfile:1.7 # syntax=docker/dockerfile:1.7
ARG RUBY_VERSION=3.1.2 ARG RUBY_VERSION=4.0.3
ARG BUNDLER_VERSION=2.3.13 ARG BUNDLER_VERSION=4.0.6
ARG base_commit=aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa
FROM ruby:${RUBY_VERSION}-slim AS runtime-base FROM ruby:${RUBY_VERSION}-slim AS runtime-base
ARG BUNDLER_VERSION ARG BUNDLER_VERSION
ARG DEBIAN_FRONTEND=noninteractive
ENV LANG=C.UTF-8 \ ENV LANG=C.UTF-8 \
BUNDLE_JOBS=4 \ BUNDLE_JOBS=4 \
@ -18,6 +20,7 @@ WORKDIR /app
RUN apt-get update -qq \ RUN apt-get update -qq \
&& apt-get install -y --no-install-recommends \ && apt-get install -y --no-install-recommends \
libpq5 \ libpq5 \
libyaml-0-2 \
nodejs \ nodejs \
openssl \ openssl \
&& rm -rf /var/lib/apt/lists/* \ && rm -rf /var/lib/apt/lists/* \
@ -26,15 +29,17 @@ RUN apt-get update -qq \
FROM runtime-base AS build-base FROM runtime-base AS build-base
ARG DEBIAN_FRONTEND=noninteractive
RUN apt-get update -qq \ RUN apt-get update -qq \
&& apt-get install -y --no-install-recommends \ && apt-get install -y --no-install-recommends \
build-essential \ build-essential \
git \ git \
libpq-dev \ libpq-dev \
libsqlite3-dev \ libsqlite3-dev \
libyaml-dev \
pkg-config \ pkg-config \
python3 \ python3 \
python3-pip \
&& rm -rf /var/lib/apt/lists/* && rm -rf /var/lib/apt/lists/*
COPY Gemfile Gemfile.lock ./ COPY Gemfile Gemfile.lock ./
@ -54,13 +59,14 @@ RUN bundle config set with 'development test' \
FROM runtime-base AS production FROM runtime-base AS production
ARG GIT_COMMIT_SHA=aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa ARG base_commit=aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa
ENV RAILS_ENV=production ENV RAILS_ENV=production \
ENV GIT_COMMIT_SHA=${GIT_COMMIT_SHA} GIT_COMMIT_SHA=${base_commit}
COPY --from=bundle-production /usr/local/bundle /usr/local/bundle COPY --from=bundle-production /usr/local/bundle /usr/local/bundle
COPY --from=bundle-production /app/vendor/bundle /app/vendor/bundle COPY --from=bundle-production /app/vendor/bundle /app/vendor/bundle
COPY Gemfile Gemfile.lock /app/ COPY Gemfile Gemfile.lock /app/
COPY app /app/app COPY app /app/app
COPY bin /app/bin COPY bin /app/bin
@ -69,30 +75,32 @@ COPY db /app/db
COPY public /app/public COPY public /app/public
COPY script /app/script COPY script /app/script
COPY config.ru Rakefile /app/ COPY config.ru Rakefile /app/
# Duplicate production environment to beta environment # Duplicate production environment to beta environment
COPY config/environments/production.rb /app/config/environments/beta.rb COPY config/environments/production.rb /app/config/environments/beta.rb
EXPOSE 3000 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 FROM runtime-base AS test
ARG GIT_COMMIT_SHA=aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa ARG base_commit=aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa
ARG DEBIAN_FRONTEND=noninteractive
ENV RAILS_ENV=test ENV RAILS_ENV=test \
ENV GIT_COMMIT_SHA=${GIT_COMMIT_SHA} GIT_COMMIT_SHA=${base_commit}
RUN apt-get update -qq \ RUN apt-get update -qq \
&& apt-get install -y --no-install-recommends \ && apt-get install -y --no-install-recommends \
libsqlite3-0 \ libsqlite3-0 \
python3 \ python3 \
python3-pip \ python3-invoke \
&& rm -rf /var/lib/apt/lists/* \ && rm -rf /var/lib/apt/lists/*
&& python3 -m pip install --no-cache-dir invoke
COPY --from=bundle-test /usr/local/bundle /usr/local/bundle COPY --from=bundle-test /usr/local/bundle /usr/local/bundle
COPY --from=bundle-test /app/vendor/bundle /app/vendor/bundle COPY --from=bundle-test /app/vendor/bundle /app/vendor/bundle
COPY Gemfile Gemfile.lock /app/ COPY Gemfile Gemfile.lock /app/
COPY app /app/app COPY app /app/app
COPY bin /app/bin COPY bin /app/bin

12
Gemfile
View File

@ -3,12 +3,12 @@
source 'https://rubygems.org' source 'https://rubygems.org'
git_source(:github) { |repo| "https://github.com/#{repo}.git" } 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' # Bundle edge Rails instead: gem 'rails', github: 'rails/rails'
gem 'rails', '~> 7.0.3' gem 'rails', '~> 8.1'
# Use Puma as the app server # 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 # Build JSON APIs with ease. Read more: https://github.com/rails/jbuilder
# gem 'jbuilder', '~> 2.5' # gem 'jbuilder', '~> 2.5'
# Use ActiveModel has_secure_password # Use ActiveModel has_secure_password
@ -28,7 +28,7 @@ gem 'bootsnap', require: false
# Authentication # Authentication
gem 'devise' 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' gem 'rack-cors'
@ -58,7 +58,7 @@ group :development, optional: true do
gem 'rubocop-rails' gem 'rubocop-rails'
gem 'solargraph' gem 'solargraph'
gem 'spring' gem 'spring'
gem 'spring-watcher-listen', '~> 2.0.0' gem 'spring-watcher-listen', '~> 2.1'
gem 'sqlite3' gem 'sqlite3'
end end
@ -67,4 +67,4 @@ group :production do
end end
# Windows does not include zoneinfo files, so bundle the tzinfo-data gem # 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]

View File

@ -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 GEM
remote: https://rubygems.org/ remote: https://rubygems.org/
specs: specs:
actioncable (7.0.8.1) action_text-trix (2.1.18)
actionpack (= 7.0.8.1) railties
activesupport (= 7.0.8.1) actioncable (8.1.3)
actionpack (= 8.1.3)
activesupport (= 8.1.3)
nio4r (~> 2.0) nio4r (~> 2.0)
websocket-driver (>= 0.6.1) websocket-driver (>= 0.6.1)
actionmailbox (7.0.8.1) zeitwerk (~> 2.6)
actionpack (= 7.0.8.1) actionmailbox (8.1.3)
activejob (= 7.0.8.1) actionpack (= 8.1.3)
activerecord (= 7.0.8.1) activejob (= 8.1.3)
activestorage (= 7.0.8.1) activerecord (= 8.1.3)
activesupport (= 7.0.8.1) activestorage (= 8.1.3)
mail (>= 2.7.1) activesupport (= 8.1.3)
net-imap mail (>= 2.8.0)
net-pop actionmailer (8.1.3)
net-smtp actionpack (= 8.1.3)
actionmailer (7.0.8.1) actionview (= 8.1.3)
actionpack (= 7.0.8.1) activejob (= 8.1.3)
actionview (= 7.0.8.1) activesupport (= 8.1.3)
activejob (= 7.0.8.1) mail (>= 2.8.0)
activesupport (= 7.0.8.1) rails-dom-testing (~> 2.2)
mail (~> 2.5, >= 2.5.4) actionpack (8.1.3)
net-imap actionview (= 8.1.3)
net-pop activesupport (= 8.1.3)
net-smtp nokogiri (>= 1.8.5)
rails-dom-testing (~> 2.0) rack (>= 2.2.4)
actionpack (7.0.8.1) rack-session (>= 1.0.1)
actionview (= 7.0.8.1)
activesupport (= 7.0.8.1)
rack (~> 2.0, >= 2.2.4)
rack-test (>= 0.6.3) rack-test (>= 0.6.3)
rails-dom-testing (~> 2.0) rails-dom-testing (~> 2.2)
rails-html-sanitizer (~> 1.0, >= 1.2.0) rails-html-sanitizer (~> 1.6)
actiontext (7.0.8.1) useragent (~> 0.16)
actionpack (= 7.0.8.1) actiontext (8.1.3)
activerecord (= 7.0.8.1) action_text-trix (~> 2.1.15)
activestorage (= 7.0.8.1) actionpack (= 8.1.3)
activesupport (= 7.0.8.1) activerecord (= 8.1.3)
activestorage (= 8.1.3)
activesupport (= 8.1.3)
globalid (>= 0.6.0) globalid (>= 0.6.0)
nokogiri (>= 1.8.5) nokogiri (>= 1.8.5)
actionview (7.0.8.1) actionview (8.1.3)
activesupport (= 7.0.8.1) activesupport (= 8.1.3)
builder (~> 3.1) builder (~> 3.1)
erubi (~> 1.4) erubi (~> 1.11)
rails-dom-testing (~> 2.0) rails-dom-testing (~> 2.2)
rails-html-sanitizer (~> 1.1, >= 1.2.0) rails-html-sanitizer (~> 1.6)
active_model_serializers (0.10.14) active_model_serializers (0.10.16)
actionpack (>= 4.1) actionpack (>= 4.1)
activemodel (>= 4.1) activemodel (>= 4.1)
case_transform (>= 0.2) case_transform (>= 0.2)
jsonapi-renderer (>= 0.1.1.beta1, < 0.3) jsonapi-renderer (>= 0.1.1.beta1, < 0.3)
activejob (7.0.8.1) activejob (8.1.3)
activesupport (= 7.0.8.1) activesupport (= 8.1.3)
globalid (>= 0.3.6) globalid (>= 0.3.6)
activemodel (7.0.8.1) activemodel (8.1.3)
activesupport (= 7.0.8.1) activesupport (= 8.1.3)
activerecord (7.0.8.1) activerecord (8.1.3)
activemodel (= 7.0.8.1) activemodel (= 8.1.3)
activesupport (= 7.0.8.1) activesupport (= 8.1.3)
activestorage (7.0.8.1) timeout (>= 0.4.0)
actionpack (= 7.0.8.1) activestorage (8.1.3)
activejob (= 7.0.8.1) actionpack (= 8.1.3)
activerecord (= 7.0.8.1) activejob (= 8.1.3)
activesupport (= 7.0.8.1) activerecord (= 8.1.3)
activesupport (= 8.1.3)
marcel (~> 1.0) marcel (~> 1.0)
mini_mime (>= 1.1.0) activesupport (8.1.3)
activesupport (7.0.8.1) base64
concurrent-ruby (~> 1.0, >= 1.0.2) bigdecimal
concurrent-ruby (~> 1.0, >= 1.3.1)
connection_pool (>= 2.2.5)
drb
i18n (>= 1.6, < 2) i18n (>= 1.6, < 2)
json
logger (>= 1.4.2)
minitest (>= 5.1) minitest (>= 5.1)
tzinfo (~> 2.0) securerandom (>= 0.3)
ast (2.4.2) tzinfo (~> 2.0, >= 2.0.5)
uri (>= 0.13.1)
ast (2.4.3)
backport (1.2.0) backport (1.2.0)
bcrypt (3.1.20) base64 (0.3.0)
benchmark (0.3.0) bcrypt (3.1.22)
bootsnap (1.18.3) benchmark (0.5.0)
bigdecimal (4.1.2)
bootsnap (1.24.0)
msgpack (~> 1.2) msgpack (~> 1.2)
builder (3.2.4) builder (3.3.0)
case_transform (0.2) case_transform (0.2)
activesupport activesupport
concurrent-ruby (1.2.3) concurrent-ruby (1.3.6)
connection_pool (3.0.2)
crass (1.0.6) crass (1.0.6)
date (3.3.4) date (3.5.1)
devise (4.9.3) devise (4.9.4)
bcrypt (~> 3.0) bcrypt (~> 3.0)
orm_adapter (~> 0.1) orm_adapter (~> 0.1)
railties (>= 4.1.0) railties (>= 4.1.0)
responders responders
warden (~> 1.2.3) warden (~> 1.2.3)
diff-lcs (1.5.1) devise_token_auth (1.2.6)
domain_name (0.6.20240107) bcrypt (~> 3.0)
e2mmap (0.1.0) devise (> 3.5.2, < 5)
erubi (1.12.0) rails (>= 4.2.0, < 8.2)
factory_bot (6.4.6) diff-lcs (1.6.2)
activesupport (>= 5.0.0) drb (2.2.3)
factory_bot_rails (6.4.3) erb (6.0.4)
factory_bot (~> 6.4) erubi (1.13.1)
railties (>= 5.0.0) factory_bot (6.5.6)
faker (3.3.1) 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) i18n (>= 1.8.11, < 2)
ffi (1.16.3) faraday (2.14.1)
globalid (1.2.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) activesupport (>= 6.1)
http-accept (1.7.0) i18n (1.14.8)
http-cookie (1.0.5)
domain_name (~> 0.5)
i18n (1.14.4)
concurrent-ruby (~> 1.0) concurrent-ruby (~> 1.0)
interactor (3.1.2) interactor (3.2.0)
interactor-rails (2.2.1) ostruct
interactor-rails (2.3.0)
interactor (~> 3.0) interactor (~> 3.0)
rails (>= 4.2) railties (>= 7.0)
jaro_winkler (1.5.6) io-console (0.8.2)
json (2.7.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) jsonapi-renderer (0.2.2)
kramdown (2.4.0) kramdown (2.5.2)
rexml rexml (>= 3.4.4)
kramdown-parser-gfm (1.1.0) kramdown-parser-gfm (1.1.0)
kramdown (~> 2.0) kramdown (~> 2.0)
language_server-protocol (3.17.0.3) language_server-protocol (3.17.0.5)
listen (3.9.0) lint_roller (1.1.0)
listen (3.10.0)
logger
rb-fsevent (~> 0.10, >= 0.10.3) rb-fsevent (~> 0.10, >= 0.10.3)
rb-inotify (~> 0.9, >= 0.9.10) rb-inotify (~> 0.9, >= 0.9.10)
loofah (2.22.0) logger (1.7.0)
loofah (2.25.1)
crass (~> 1.0.2) crass (~> 1.0.2)
nokogiri (>= 1.12.0) nokogiri (>= 1.12.0)
mail (2.8.1) mail (2.9.0)
logger
mini_mime (>= 0.1.1) mini_mime (>= 0.1.1)
net-imap net-imap
net-pop net-pop
net-smtp net-smtp
mailgun-ruby (1.2.14) mailgun-ruby (1.4.3)
rest-client (>= 2.0.2) faraday (~> 2.1)
marcel (1.0.4) faraday-multipart (< 2)
method_source (1.0.0) mini_mime
mime-types (3.5.2) zeitwerk
mime-types-data (~> 3.2015) marcel (1.1.0)
mime-types-data (3.2024.0305)
mini_mime (1.1.5) mini_mime (1.1.5)
minitest (5.22.3) minitest (6.0.5)
msgpack (1.7.2) drb (~> 2.0)
net-imap (0.4.10) 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 date
net-protocol net-protocol
net-pop (0.1.2) net-pop (0.1.2)
net-protocol net-protocol
net-protocol (0.2.2) net-protocol (0.2.2)
timeout timeout
net-smtp (0.5.0) net-smtp (0.5.1)
net-protocol net-protocol
netrc (0.11.0) nio4r (2.7.5)
nio4r (2.7.1) nokogiri (1.19.3-aarch64-linux-gnu)
nokogiri (1.16.3-aarch64-linux)
racc (~> 1.4) racc (~> 1.4)
nokogiri (1.16.3-arm64-darwin) nokogiri (1.19.3-aarch64-linux-musl)
racc (~> 1.4) racc (~> 1.4)
nokogiri (1.16.3-x86_64-linux) nokogiri (1.19.3-arm64-darwin)
racc (~> 1.4) 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) orm_adapter (0.5.0)
parallel (1.24.0) ostruct (0.6.3)
parser (3.3.0.5) parallel (2.1.0)
parser (3.3.11.1)
ast (~> 2.4.1) ast (~> 2.4.1)
racc racc
pg (1.5.6) pg (1.6.3-aarch64-linux)
puma (5.6.8) 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) nio4r (~> 2.0)
racc (1.7.3) racc (1.8.1)
rack (2.2.9) rack (3.2.6)
rack-cors (2.0.2) rack-cors (3.0.0)
rack (>= 2.0.0) logger
rack-test (2.1.0) 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) rack (>= 1.3)
rackup (2.3.1)
rack (>= 3)
railroady (1.6.0) railroady (1.6.0)
rails (7.0.8.1) rails (8.1.3)
actioncable (= 7.0.8.1) actioncable (= 8.1.3)
actionmailbox (= 7.0.8.1) actionmailbox (= 8.1.3)
actionmailer (= 7.0.8.1) actionmailer (= 8.1.3)
actionpack (= 7.0.8.1) actionpack (= 8.1.3)
actiontext (= 7.0.8.1) actiontext (= 8.1.3)
actionview (= 7.0.8.1) actionview (= 8.1.3)
activejob (= 7.0.8.1) activejob (= 8.1.3)
activemodel (= 7.0.8.1) activemodel (= 8.1.3)
activerecord (= 7.0.8.1) activerecord (= 8.1.3)
activestorage (= 7.0.8.1) activestorage (= 8.1.3)
activesupport (= 7.0.8.1) activesupport (= 8.1.3)
bundler (>= 1.15.0) bundler (>= 1.15.0)
railties (= 7.0.8.1) railties (= 8.1.3)
rails-dom-testing (2.2.0) rails-dom-testing (2.3.0)
activesupport (>= 5.0.0) activesupport (>= 5.0.0)
minitest minitest
nokogiri (>= 1.6) nokogiri (>= 1.6)
rails-html-sanitizer (1.6.0) rails-html-sanitizer (1.7.0)
loofah (~> 2.21) loofah (~> 2.25)
nokogiri (~> 1.14) 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 (7.0.8.1) railties (8.1.3)
actionpack (= 7.0.8.1) actionpack (= 8.1.3)
activesupport (= 7.0.8.1) activesupport (= 8.1.3)
method_source irb (~> 1.13)
rackup (>= 1.0.0)
rake (>= 12.2) rake (>= 12.2)
thor (~> 1.0) thor (~> 1.0, >= 1.2.2)
zeitwerk (~> 2.5) tsort (>= 0.2)
zeitwerk (~> 2.6)
rainbow (3.1.1) rainbow (3.1.1)
rake (13.2.1) rake (13.4.2)
rb-fsevent (0.11.2) rb-fsevent (0.11.2)
rb-inotify (0.10.1) rb-inotify (0.11.1)
ffi (~> 1.0) ffi (~> 1.0)
rbs (2.8.4) rbs (3.10.4)
regexp_parser (2.9.0) logger
responders (3.1.1) tsort
actionpack (>= 5.2) rdoc (7.2.0)
railties (>= 5.2) erb
rest-client (2.1.0) psych (>= 4.0.0)
http-accept (>= 1.7.0, < 2.0) tsort
http-cookie (>= 1.0.2, < 2.0) regexp_parser (2.12.0)
mime-types (>= 1.16, < 4.0) reline (0.6.3)
netrc (~> 0.8) io-console (~> 0.5)
reverse_markdown (2.1.1) responders (3.2.0)
actionpack (>= 7.0)
railties (>= 7.0)
reverse_markdown (3.0.2)
nokogiri nokogiri
rexml (3.2.6) rexml (3.4.4)
rspec-core (3.13.0) rspec-core (3.13.6)
rspec-support (~> 3.13.0) rspec-support (~> 3.13.0)
rspec-expectations (3.13.0) rspec-expectations (3.13.5)
diff-lcs (>= 1.2.0, < 2.0) diff-lcs (>= 1.2.0, < 2.0)
rspec-support (~> 3.13.0) rspec-support (~> 3.13.0)
rspec-mocks (3.13.0) rspec-mocks (3.13.8)
diff-lcs (>= 1.2.0, < 2.0) diff-lcs (>= 1.2.0, < 2.0)
rspec-support (~> 3.13.0) rspec-support (~> 3.13.0)
rspec-rails (6.1.2) rspec-rails (8.0.4)
actionpack (>= 6.1) actionpack (>= 7.2)
activesupport (>= 6.1) activesupport (>= 7.2)
railties (>= 6.1) railties (>= 7.2)
rspec-core (~> 3.13) rspec-core (>= 3.13.0, < 5.0.0)
rspec-expectations (~> 3.13) rspec-expectations (>= 3.13.0, < 5.0.0)
rspec-mocks (~> 3.13) rspec-mocks (>= 3.13.0, < 5.0.0)
rspec-support (~> 3.13) rspec-support (>= 3.13.0, < 5.0.0)
rspec-support (3.13.1) rspec-support (3.13.7)
rubocop (1.62.1) rubocop (1.86.1)
json (~> 2.3) json (~> 2.3)
language_server-protocol (>= 3.17.0) language_server-protocol (~> 3.17.0.2)
parallel (~> 1.10) lint_roller (~> 1.1.0)
parallel (>= 1.10)
parser (>= 3.3.0.2) parser (>= 3.3.0.2)
rainbow (>= 2.2.2, < 4.0) rainbow (>= 2.2.2, < 4.0)
regexp_parser (>= 1.8, < 3.0) regexp_parser (>= 2.9.3, < 3.0)
rexml (>= 3.2.5, < 4.0) rubocop-ast (>= 1.49.0, < 2.0)
rubocop-ast (>= 1.31.1, < 2.0)
ruby-progressbar (~> 1.7) ruby-progressbar (~> 1.7)
unicode-display_width (>= 2.4.0, < 3.0) unicode-display_width (>= 2.4.0, < 4.0)
rubocop-ast (1.31.2) rubocop-ast (1.49.1)
parser (>= 3.3.0.4) parser (>= 3.3.7.2)
rubocop-performance (1.21.0) prism (~> 1.7)
rubocop (>= 1.48.1, < 2.0) rubocop-performance (1.26.1)
rubocop-ast (>= 1.31.1, < 2.0) lint_roller (~> 1.1)
rubocop-rails (2.24.1) rubocop (>= 1.75.0, < 2.0)
rubocop-ast (>= 1.47.1, < 2.0)
rubocop-rails (2.34.3)
activesupport (>= 4.2.0) activesupport (>= 4.2.0)
lint_roller (~> 1.1)
rack (>= 1.1) rack (>= 1.1)
rubocop (>= 1.33.0, < 2.0) rubocop (>= 1.75.0, < 2.0)
rubocop-ast (>= 1.31.1, < 2.0) rubocop-ast (>= 1.44.0, < 2.0)
ruby-prof (1.7.1) ruby-prof (2.0.4)
base64
ostruct
ruby-progressbar (1.13.0) ruby-progressbar (1.13.0)
shoulda-matchers (6.2.0) securerandom (0.4.1)
activesupport (>= 5.2.0) shoulda-matchers (7.0.1)
solargraph (0.50.0) activesupport (>= 7.1)
solargraph (0.58.3)
ast (~> 2.4.3)
backport (~> 1.2) backport (~> 1.2)
benchmark benchmark (~> 0.4)
bundler (~> 2.0) bundler (>= 2.0)
diff-lcs (~> 1.4) diff-lcs (~> 1.4)
e2mmap jaro_winkler (~> 1.6, >= 1.6.1)
jaro_winkler (~> 1.5)
kramdown (~> 2.3) kramdown (~> 2.3)
kramdown-parser-gfm (~> 1.1) kramdown-parser-gfm (~> 1.1)
logger (~> 1.6)
observer (~> 0.1)
open3 (~> 0.2.1)
ostruct (~> 0.6)
parser (~> 3.0) parser (~> 3.0)
rbs (~> 2.0) prism (~> 1.4)
reverse_markdown (~> 2.0) rbs (>= 3.6.1, <= 4.0.0.dev.4)
rubocop (~> 1.38) reverse_markdown (~> 3.0)
rubocop (~> 1.76)
thor (~> 1.0) thor (~> 1.0)
tilt (~> 2.0) tilt (~> 2.0)
yard (~> 0.9, >= 0.9.24) yard (~> 0.9, >= 0.9.24)
spring (2.1.1) yard-activesupport-concern (~> 0.0)
spring-watcher-listen (2.0.1) yard-solargraph (~> 0.1)
spring (4.4.2)
spring-watcher-listen (2.1.0)
listen (>= 2.7, < 4.0) listen (>= 2.7, < 4.0)
spring (>= 1.2, < 3.0) spring (>= 4)
sqlite3 (1.7.3-aarch64-linux) sqlite3 (2.9.3-aarch64-linux-gnu)
sqlite3 (1.7.3-arm64-darwin) sqlite3 (2.9.3-aarch64-linux-musl)
sqlite3 (1.7.3-x86_64-linux) sqlite3 (2.9.3-arm64-darwin)
thor (1.3.1) sqlite3 (2.9.3-x86_64-linux-gnu)
tilt (2.3.0) stringio (3.2.0)
timeout (0.4.1) thor (1.5.0)
tilt (2.7.0)
timeout (0.6.1)
tsort (0.2.0)
tzinfo (2.0.6) tzinfo (2.0.6)
concurrent-ruby (~> 1.0) 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) warden (1.2.9)
rack (>= 2.0.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.0)
websocket-extensions (0.1.5) websocket-extensions (0.1.5)
yard (0.9.36) yard (0.9.43)
zeitwerk (2.6.13) yard-activesupport-concern (0.0.1)
yard (>= 0.8)
yard-solargraph (0.1.0)
yard (~> 0.9)
zeitwerk (2.7.5)
PLATFORMS PLATFORMS
aarch64-linux
aarch64-linux-musl aarch64-linux-musl
arm64-darwin-22 arm64-darwin-22
arm64-darwin-23 arm64-darwin-23
arm64-darwin-24 arm64-darwin-24
arm64-darwin-25 arm64-darwin-25
x86_64-linux x86_64-linux
x86_64-linux-gnu
DEPENDENCIES DEPENDENCIES
active_model_serializers active_model_serializers
bootsnap bootsnap
devise devise
devise_token_auth! devise_token_auth
factory_bot_rails factory_bot_rails
faker faker
interactor interactor
@ -328,10 +409,10 @@ DEPENDENCIES
listen (~> 3.3) listen (~> 3.3)
mailgun-ruby mailgun-ruby
pg pg
puma (~> 5.0) puma (~> 8.0)
rack-cors rack-cors
railroady railroady
rails (~> 7.0.3) rails (~> 8.1)
rspec-rails rspec-rails
rubocop rubocop
rubocop-performance rubocop-performance
@ -340,12 +421,12 @@ DEPENDENCIES
shoulda-matchers shoulda-matchers
solargraph solargraph
spring spring
spring-watcher-listen (~> 2.0.0) spring-watcher-listen (~> 2.1)
sqlite3 sqlite3
tzinfo-data tzinfo-data
RUBY VERSION RUBY VERSION
ruby 3.1.2p20 ruby 4.0.3
BUNDLED WITH BUNDLED WITH
2.3.7 4.0.6

View File

@ -30,6 +30,10 @@ bundle exec rspec
Leader/follower tournament sync docs: Leader/follower tournament sync docs:
- [doc/leader_follower.md](doc/leader_follower.md) - [doc/leader_follower.md](doc/leader_follower.md)
- [doc/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/timer_api.md](doc/timer_api.md)
- [doc/beamer_live_updates.md](doc/beamer_live_updates.md) - [doc/beamer_live_updates.md](doc/beamer_live_updates.md)
- [doc/tournament_live_updates.md](doc/tournament_live_updates.md) - [doc/tournament_live_updates.md](doc/tournament_live_updates.md)

View File

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

View File

@ -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

View File

@ -3,11 +3,14 @@
class ApplicationController < ActionController::API class ApplicationController < ActionController::API
include DeviseTokenAuth::Concerns::SetUserByToken include DeviseTokenAuth::Concerns::SetUserByToken
around_action :record_request_metrics
before_action :configure_permitted_parameters, if: :devise_controller? before_action :configure_permitted_parameters, if: :devise_controller?
rescue_from ActionController::ParameterMissing do |e| rescue_from ActionController::ParameterMissing do |e|
render json: { error: e.message }, status: :bad_request render json: { error: e.message }, status: :bad_request
end end
rescue_from ActiveRecord::RecordInvalid, with: :render_record_invalid_error
rescue_from ActiveRecord::RecordNotFound, with: :render_not_found_error
protected protected
@ -18,6 +21,23 @@ class ApplicationController < ActionController::API
private 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) def require_owner!(owner)
render_forbidden_error if owner != current_user render_forbidden_error if owner != current_user
end end
@ -37,10 +57,19 @@ class ApplicationController < ActionController::API
end end
def require_writable_tournament!(tournament) def require_writable_tournament!(tournament)
render_read_only_error if tournament.read_only_mode? render_read_only_error if tournament&.read_only_mode?
end end
def render_not_found_error(exception) 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
end end

View File

@ -36,7 +36,7 @@ class BeamersController < ApplicationController
push_sync_if_needed! push_sync_if_needed!
render json: beamer, status: :created render json: beamer, status: :created
else else
render json: beamer.errors, status: :unprocessable_entity render json: beamer.errors, status: :unprocessable_content
end end
end end
@ -46,7 +46,7 @@ class BeamersController < ApplicationController
push_sync_if_needed! push_sync_if_needed!
render json: @beamer render json: @beamer
else else
render json: @beamer.errors, status: :unprocessable_entity render json: @beamer.errors, status: :unprocessable_content
end end
end end

View File

@ -25,6 +25,6 @@ class BetsController < ApplicationController
end end
def handle_user_service_error(exception) def handle_user_service_error(exception)
render json: { error: exception.message }, status: :unprocessable_entity render json: { error: exception.message }, status: :unprocessable_content
end end
end end

View File

@ -13,16 +13,40 @@ class MatchScoresController < ApplicationController
# PATCH/PUT /scores/1 # PATCH/PUT /scores/1
def update 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) if @match_score.update(match_score_params)
UpdateGroupsGroupScoresAndSave.call(group: @match_score.match.group) if @match_score.part_of_group_match? group_scores = []
push_sync_if_needed!(@match_score.tournament) if @match_score.part_of_group_match?
TournamentLiveBroadcast.call(@match_score.tournament.id) 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 render json: @match_score
else else
render json: @match_score.errors, status: :unprocessable_entity render json: @match_score.errors, status: :unprocessable_content
raise ActiveRecord::Rollback
end end
end end
return if synced_tournament.nil?
push_sync_if_needed!(synced_tournament)
TournamentLiveBroadcast.call(synced_tournament.id)
end
private private
# Use callbacks to share common setup or constraints between actions. # Use callbacks to share common setup or constraints between actions.
@ -35,6 +59,30 @@ class MatchScoresController < ApplicationController
params.slice(:points, :hidden_points).permit! params.slice(:points, :hidden_points).permit!
end 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) def push_sync_if_needed!(tournament)
TournamentSyncEnqueue.call(tournament) TournamentSyncEnqueue.call(tournament)
end end

View File

@ -7,9 +7,11 @@ class MatchesController < ApplicationController
before_action :set_swap_target, only: %i[swap] before_action :set_swap_target, only: %i[swap]
before_action -> { require_owner! @match.owner }, only: %i[update swap] before_action -> { require_owner! @match.owner }, only: %i[update swap]
before_action -> { require_owner! @group.owner }, only: %i[create] 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!(@match.tournament) }, only: %i[update swap]
before_action -> { require_writable_tournament!(@group.tournament) }, only: %i[create] before_action -> { require_writable_tournament!(@group.tournament) }, only: %i[create]
before_action -> { require_writable_tournament!(@tournament) }, only: %i[metadata]
# GET/tournaments/1/matches # GET/tournaments/1/matches
def index def index
@ -23,49 +25,74 @@ class MatchesController < ApplicationController
# POST /groups/:group_id/matches # POST /groups/:group_id/matches
def create def create
match = nil
synced_tournament = nil
Match.transaction do
TournamentTransactionLogger.lock_tournament!(@group.tournament)
match = build_decider_match match = build_decider_match
if match.errors.any? if match.errors.any?
render json: match.errors, status: :unprocessable_entity render json: match.errors, status: :unprocessable_content
raise ActiveRecord::Rollback
elsif match.save elsif match.save
UpdateGroupsGroupScoresAndSave.call(group: @group) UpdateGroupsGroupScoresAndSave.call(group: @group)
push_sync_if_needed!(@group.tournament) group_scores = group_scores_for_log(@group)
TournamentLiveBroadcast.call(@group.tournament.id) 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 render json: match, include: %w[match_scores.team group], status: :created
else else
render json: match.errors, status: :unprocessable_entity render json: match.errors, status: :unprocessable_content
raise ActiveRecord::Rollback
end end
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 # PATCH/PUT /matches/1
def update def update
new_state = match_params['state'] new_state = match_params['state']
synced_tournament = nil synced_tournament = nil
@transaction_log_group_scores = []
@transaction_log_advanced_match_ids = []
Match.transaction do Match.transaction do
if @match.update(match_params) synced_tournament = update_match_inside_transaction(new_state)
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
end end
push_sync_if_needed!(synced_tournament) unless synced_tournament.nil? push_sync_if_needed!(synced_tournament) unless synced_tournament.nil?
TournamentLiveBroadcast.call(synced_tournament.id) unless synced_tournament.nil? TournamentLiveBroadcast.call(synced_tournament.id) unless synced_tournament.nil?
end 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 # PATCH /matches/1/swap
def swap def swap
unless @match.swappable_with?(@swap_target) unless @match.swappable_with?(@swap_target)
render json: { error: 'Matches must be different group matches from the same group' }, render json: { error: 'Matches must be different group matches from the same group' },
status: :unprocessable_entity status: :unprocessable_content
return return
end end
@ -93,33 +120,94 @@ class MatchesController < ApplicationController
def handle_match_end def handle_match_end
if @match.group_match? if @match.group_match?
group = @match.group 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 end
return if @match.group_match?
if @match.winner.nil? if @match.winner.nil?
render json: { error: 'Stopping undecided Matches isn\'t allowed in playoff stage' }, render json: { error: 'Stopping undecided Matches isn\'t allowed in playoff stage' },
status: :unprocessable_entity status: :unprocessable_content
raise ActiveRecord::Rollback raise ActiveRecord::Rollback
end 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' }, render json: { error: 'Moving Team one stage down failed' },
status: :unprocessable_entity status: :unprocessable_content
raise ActiveRecord::Rollback raise ActiveRecord::Rollback
end 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 def validate_params
case match_params['state'] return unless params.key?(:state)
when 'in_progress'
render json: { error: 'Match can\'t start in this state' }, status: :unprocessable_entity unless @match.not_started? transition_error = match_transition_error(@match, match_params['state'])
when 'finished' return if transition_error.nil?
render json: { error: 'Match can\'t finish in this state' }, status: :unprocessable_entity unless @match.in_progress?
else render json: { error: transition_error }, status: :unprocessable_content
render json: { error: 'Invalid target state' }, status: :unprocessable_entity
end
end end
def set_match def set_match
@ -142,13 +230,52 @@ class MatchesController < ApplicationController
end end
def match_params def match_params
params.slice(:state).permit! params.slice(:state, :location, :start_time, :notes).permit!
end end
def match_index_params def match_index_params
params.slice(:state, :stage_id, :stage_level, :position, :group_stage_position).permit! params.slice(:state, :stage_id, :stage_level, :position, :group_stage_position).permit!
end 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 def create_match_params
params.slice(:team_ids).permit(team_ids: []) params.slice(:team_ids).permit(team_ids: [])
end end
@ -162,12 +289,33 @@ class MatchesController < ApplicationController
state: :not_started, state: :not_started,
position: (@group.matches.maximum(:position) || -1) + 1, position: (@group.matches.maximum(:position) || -1) + 1,
decider: true, decider: true,
match_scores: teams.map { |team| MatchScore.new(team:) } match_scores: teams.map { |team| MatchScore.new(team: team) }
).tap do |match| ).tap do |match|
match.errors.add(:team_ids, 'must contain exactly two teams from this group') if team_ids.size != 2 || teams.size != 2 match.errors.add(:team_ids, 'must contain exactly two teams from this group') if team_ids.size != 2 || teams.size != 2
end end
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) def push_sync_if_needed!(tournament)
TournamentSyncEnqueue.call(tournament) TournamentSyncEnqueue.call(tournament)
end end

View File

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

View File

@ -13,64 +13,138 @@ class StagesController < ApplicationController
# PUT /stages/1 # PUT /stages/1
def update def update
if stage_params[:state] == 'finished' return render_invalid_stage_state unless stage_params[:state] == 'finished'
unless @stage.state == 'in_progress' return render_stage_not_running unless @stage.in_progress?
render json: { error: 'Only running group stages can be finished' }, status: :unprocessable_entity
return
end
synced_tournament = nil finish_group_stage
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
end end
private private
def handle_group_stage_end def finish_group_stage
unless @stage.over? synced_tournament = nil
render json: { @playoff_start_failure = nil
error: 'Group Stage still has some matches that are not over yet. Finish them to generate playoffs' @transaction_log_advancing_teams = []
}, status: :unprocessable_entity
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 raise ActiveRecord::Rollback
end 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| @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 raise ActiveRecord::Rollback
end end
true
end
def no_blocking_ties_for_playoff_start?
blocking_ties = GroupStageService.blocking_ties_for(@stage) blocking_ties = GroupStageService.blocking_ties_for(@stage)
if blocking_ties.any? return true if blocking_ties.empty?
render json: {
error: 'Playoffs cannot be generated yet because a decider match is required for an advancing place', render_playoff_start_failure(reason: 'decider_missing', blocking_ties: blocking_ties)
blocking_ties: false
}, status: :unprocessable_entity
raise ActiveRecord::Rollback
end end
return if AddPlayoffsToTournamentAndSave.call(tournament: @stage.tournament, def render_playoff_start_failure(reason:, blocking_ties: [])
teams: GroupStageService.get_advancing_teams(@stage)).success? 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 def playoff_start_failure_payload(reason, blocking_ties)
raise ActiveRecord::Rollback 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 end
def set_stage def set_stage

View File

@ -15,7 +15,7 @@ class TeamActionItemsController < ApplicationController
) )
render json: @team_action_item render json: @team_action_item
else else
render json: @team_action_item.errors, status: :unprocessable_entity render json: @team_action_item.errors, status: :unprocessable_content
end end
end end

View File

@ -20,9 +20,9 @@ class TeamActionListsController < ApplicationController
render json: team_action_list, status: :created render json: team_action_list, status: :created
rescue TeamActionListService::InvalidSourceError, TeamActionListService::EmptySourceError => e 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 rescue ActiveRecord::RecordInvalid => e
render json: e.record.errors, status: :unprocessable_entity render json: e.record.errors, status: :unprocessable_content
end end
# PATCH /tournaments/:tournament_id/team_action_lists/:key/teams/:team_id # PATCH /tournaments/:tournament_id/team_action_lists/:key/teams/:team_id
@ -33,7 +33,7 @@ class TeamActionListsController < ApplicationController
) )
render json: @team_action_item render json: @team_action_item
else else
render json: @team_action_item.errors, status: :unprocessable_entity render json: @team_action_item.errors, status: :unprocessable_content
end end
end end

View File

@ -3,7 +3,7 @@
class TeamsController < ApplicationController class TeamsController < ApplicationController
before_action :set_team, only: %i[show update] before_action :set_team, only: %i[show update]
before_action :authenticate_user!, only: %i[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] before_action -> { require_writable_tournament!(@team.tournament) }, only: %i[update]
# GET /teams/1 # GET /teams/1
@ -13,12 +13,19 @@ class TeamsController < ApplicationController
# PATCH/PUT /teams/1 # PATCH/PUT /teams/1
def update 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) push_sync_if_needed!(@team.tournament)
render json: @team render json: @team
else rescue ActiveRecord::RecordInvalid => e
render json: @team.errors, status: :unprocessable_entity render json: e.record.errors, status: :unprocessable_content
end
end end
private private
@ -31,6 +38,81 @@ class TeamsController < ApplicationController
params.slice(:name).permit! params.slice(:name).permit!
end 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) def push_sync_if_needed!(tournament)
TournamentSyncEnqueue.call(tournament) TournamentSyncEnqueue.call(tournament)
end end

View File

@ -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

View File

@ -12,15 +12,19 @@ class TournamentsController < ApplicationController
end end
before_action :set_tournament_for_show, only: %i[show] before_action :set_tournament_for_show, only: %i[show]
before_action :set_tournament, only: %i[update destroy set_timer timer sync_state test_sync] before_action :set_tournament,
before_action :authenticate_user!, only: %i[create update destroy set_timer test_sync] only: %i[update destroy destroy_playoffs set_timer timer sync_state test_sync start_next_group_matches]
before_action -> { require_owner! @tournament.owner }, only: %i[update destroy set_timer test_sync] 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_create_params, only: %i[create]
before_action :validate_update_params, only: %i[update] before_action :validate_update_params, only: %i[update]
before_action :validate_set_timer_params, only: %i[set_timer] before_action :validate_set_timer_params, only: %i[set_timer]
before_action :ensure_update_allowed!, only: %i[update] before_action :ensure_update_allowed!, only: %i[update]
before_action :ensure_destroy_allowed!, only: %i[destroy] before_action :ensure_destroy_allowed!, only: %i[destroy]
before_action :ensure_timer_update_allowed!, only: %i[set_timer] 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] before_action :authorize_sync_request!, only: %i[sync_state]
rescue_from ActiveRecord::RecordNotFound, with: :render_not_found_error rescue_from ActiveRecord::RecordNotFound, with: :render_not_found_error
@ -62,9 +66,10 @@ class TournamentsController < ApplicationController
if tournament.read_only_mode? if tournament.read_only_mode?
if tournament.save if tournament.save
TournamentTransactionLogger.record_tournament_created!(tournament: tournament, user: current_user)
render json: tournament, status: :created, location: tournament render json: tournament, status: :created, location: tournament
else else
render json: tournament.errors, status: :unprocessable_entity render json: tournament.errors, status: :unprocessable_content
end end
return return
end end
@ -75,7 +80,7 @@ class TournamentsController < ApplicationController
params.require(:playoff_teams_amount) params.require(:playoff_teams_amount)
groups = organize_teams_in_groups(teams) groups = organize_teams_in_groups(teams)
# add groups to tournament # add groups to tournament
result = AddGroupStageToTournamentAndSave.call(tournament:, groups:) result = AddGroupStageToTournamentAndSave.call(tournament: tournament, groups: groups)
# associate provided teams with tournament on success # associate provided teams with tournament on success
tournament.teams = groups.flatten if result.success? tournament.teams = groups.flatten if result.success?
else else
@ -84,18 +89,19 @@ class TournamentsController < ApplicationController
# associate provided teams with tournament # associate provided teams with tournament
tournament.teams = teams tournament.teams = teams
# add playoff stage to tournament # add playoff stage to tournament
result = AddPlayoffsToTournamentAndSave.call(tournament:, teams: tournament.teams) result = AddPlayoffsToTournamentAndSave.call(tournament: tournament, teams: tournament.teams)
end end
# validate tournament # validate tournament
unless tournament.valid? unless tournament.valid?
render json: tournament.errors, status: :unprocessable_entity render json: tournament.errors, status: :unprocessable_content
return return
end end
# return appropriate result # return appropriate result
if result.success? if result.success?
TournamentTransactionLogger.record_tournament_created!(tournament: result.tournament, user: current_user)
render json: result.tournament, status: :created, location: result.tournament render json: result.tournament, status: :created, location: result.tournament
else else
render json: { error: 'Tournament generation failed' }, status: :unprocessable_entity render json: { error: 'Tournament generation failed' }, status: :unprocessable_content
end end
end end
@ -114,7 +120,7 @@ class TournamentsController < ApplicationController
synced_tournament = @tournament synced_tournament = @tournament
render json: @tournament render json: @tournament
else else
render json: @tournament.errors, status: :unprocessable_entity render json: @tournament.errors, status: :unprocessable_content
raise ActiveRecord::Rollback raise ActiveRecord::Rollback
end end
end end
@ -126,6 +132,30 @@ class TournamentsController < ApplicationController
@tournament.destroy @tournament.destroy
end 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 # GET /tournaments/:id/timer
def timer def timer
render json: serialize_timer_state(@tournament) render json: serialize_timer_state(@tournament)
@ -133,27 +163,44 @@ class TournamentsController < ApplicationController
# PATCH /tournaments/:id/timer # PATCH /tournaments/:id/timer
def set_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) 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 render json: @tournament
else else
render json: @tournament.errors, status: :unprocessable_entity render json: @tournament.errors, status: :unprocessable_content
raise ActiveRecord::Rollback
end end
end end
push_sync_if_needed!(synced_tournament) unless synced_tournament.nil?
end
def sync_state def sync_state
TournamentSnapshotImporter.import!(tournament: @tournament, snapshot: sync_state_params) TournamentSnapshotImporter.import!(tournament: @tournament, snapshot: sync_state_params)
TournamentLiveBroadcast.call(@tournament.id) TournamentLiveBroadcast.call(@tournament.id)
render json: @tournament, include: '**' render json: @tournament, include: '**'
rescue KeyError, ArgumentError => e rescue KeyError, ArgumentError => e
render json: { error: e.message }, status: :unprocessable_entity render json: { error: e.message }, status: :unprocessable_content
end end
def test_sync def test_sync
unless @tournament.sync_push_enabled? unless @tournament.sync_push_enabled?
return render json: { return render json: {
error: 'Tournament sync test requires writable tournament with sync_target_url and sync_auth_token' error: 'Tournament sync test requires writable tournament with sync_target_url and sync_auth_token'
}, status: :unprocessable_entity }, status: :unprocessable_content
end end
TournamentSyncPusher.push!(@tournament) TournamentSyncPusher.push!(@tournament)
@ -166,19 +213,52 @@ class TournamentsController < ApplicationController
}, status: :bad_gateway }, status: :bad_gateway
end 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 private
def timer_params def timer_params
{ timer_params = {
timestamp: params[:timestamp], timer_reason: params[:timer_reason],
timer_mode: params[:timer_mode] 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 end
def serialize_timer_state(tournament) def serialize_timer_state(tournament)
{ {
timestamp: tournament.timestamp&.iso8601, timestamp: tournament.timestamp&.iso8601,
timer_mode: tournament.timer_mode timer_mode: tournament.timer_mode,
timer_reason: tournament.timer_reason,
timer_reason_text: tournament.timer_reason_text
}
end
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 end
@ -196,7 +276,14 @@ class TournamentsController < ApplicationController
if team[:id] if team[:id]
Team.find team[:id] Team.find team[:id]
elsif team[:name] 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
end end
@ -209,7 +296,7 @@ class TournamentsController < ApplicationController
@tournament = profiling.measure('load_tournament') do @tournament = profiling.measure('load_tournament') do
Tournament.includes( Tournament.includes(
:user, :user,
:teams, { teams: :players },
stages: [ stages: [
{ matches: { match_scores: :team } }, { matches: { match_scores: :team } },
{ groups: [ { groups: [
@ -245,6 +332,7 @@ class TournamentsController < ApplicationController
params.slice( params.slice(
:name, :description, :public, :teams, :group_stage, :playoff_teams_amount, :name, :description, :public, :teams, :group_stage, :playoff_teams_amount,
:instant_finalists_amount, :intermediate_round_participants_amount, :instant_finalists_amount, :intermediate_round_participants_amount,
:location, :starts_at, :ends_at, :website_url,
:read_only_mode, :sync_target_url, :sync_auth_token :read_only_mode, :sync_target_url, :sync_auth_token
).permit! ).permit!
end end
@ -253,9 +341,9 @@ class TournamentsController < ApplicationController
return if ActiveModel::Type::Boolean.new.cast(params[:read_only_mode]) return if ActiveModel::Type::Boolean.new.cast(params[:read_only_mode])
teams = params['teams'] 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 end
def only_playoff_teams_amount_changed def only_playoff_teams_amount_changed
@ -286,7 +374,7 @@ class TournamentsController < ApplicationController
render json: { render json: {
error: 'playoff_teams_amount, instant_finalists_amount and intermediate_round_participants_amount don\'t match' error: 'playoff_teams_amount, instant_finalists_amount and intermediate_round_participants_amount don\'t match'
}, status: :unprocessable_entity }, status: :unprocessable_content
end end
def sync_state_params def sync_state_params
@ -296,15 +384,13 @@ class TournamentsController < ApplicationController
def authorize_sync_request! def authorize_sync_request!
unless @tournament.sync_accepts_push? unless @tournament.sync_accepts_push?
return render json: { error: 'Tournament is not configured to accept sync pushes' }, return render json: { error: 'Tournament is not configured to accept sync pushes' },
status: :unprocessable_entity status: :unprocessable_content
end end
token = request.authorization.to_s.delete_prefix('Bearer ').presence || request.headers['X-Tournament-Sync-Token'].to_s 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? return render json: { error: 'Missing sync token' }, status: :unauthorized if token.blank?
matches = token.bytesize == @tournament.sync_auth_token.to_s.bytesize && return if @tournament.sync_token_matches?(token)
ActiveSupport::SecurityUtils.secure_compare(token, @tournament.sync_auth_token.to_s)
return if matches
render json: { error: 'Invalid sync token' }, status: :unauthorized render json: { error: 'Invalid sync token' }, status: :unauthorized
end end
@ -337,72 +423,125 @@ class TournamentsController < ApplicationController
timestamp = params[:timestamp] timestamp = params[:timestamp]
timestamp_seconds = params[:timestamp_seconds] timestamp_seconds = params[:timestamp_seconds]
timer_mode = params[:timer_mode] timer_mode = params[:timer_mode]
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]) clear_timer = ActiveModel::Type::Boolean.new.cast(params[:clear_timer])
if clear_timer return validate_clear_timer_params(timestamp, timestamp_seconds, timer_mode, timer_reason, timer_reason_text) 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' }, return render_timer_validation_error('Invalid timer_mode') if timer_mode.present? && !Tournament::TIMER_MODES.include?(timer_mode)
status: :unprocessable_entity
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 end
params[:timestamp] = nil def validate_timer_timestamp_params(timestamp, timestamp_seconds, timer_mode)
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
if timestamp.present? && timestamp_seconds.present? if timestamp.present? && timestamp_seconds.present?
return render json: { error: 'Only one of timestamp or timestamp_seconds is allowed' }, return render_timer_validation_error('Only one of timestamp or timestamp_seconds is allowed')
status: :unprocessable_entity
end end
if timer_mode == 'countup' && timestamp_seconds.present? if timer_mode == 'countup' && timestamp_seconds.present?
return render json: { error: 'timestamp_seconds is only allowed for countdown mode' }, return render_timer_validation_error('timestamp_seconds is only allowed for countdown mode')
status: :unprocessable_entity
end end
if timestamp_seconds.present? return validate_timestamp_seconds_param(timestamp_seconds) if timestamp_seconds.present?
begin return validate_timestamp_param(timestamp, timer_mode) if timestamp.present?
timestamp_seconds = Integer(timestamp_seconds)
rescue ArgumentError false
return render json: { error: 'Invalid seconds format' }, status: :unprocessable_entity
end end
if timestamp_seconds <= 0 def validate_timestamp_seconds_param(timestamp_seconds)
return render json: { error: 'Timestamp must be in the future for countdown mode' }, seconds = Integer(timestamp_seconds)
status: :unprocessable_entity return render_timer_validation_error('Timestamp must be in the future for countdown mode') if seconds <= 0
end
parsed_time = Time.zone.now + timestamp_seconds params[:timestamp] = Time.zone.now + seconds
params[:timestamp] = parsed_time
params[:timer_mode] = 'countdown' params[:timer_mode] = 'countdown'
elsif timestamp.present? rescue ArgumentError
begin render_timer_validation_error('Invalid seconds format')
end
def validate_timestamp_param(timestamp, timer_mode)
parsed_time = Time.zone.parse(timestamp) parsed_time = Time.zone.parse(timestamp)
if parsed_time.nil? if parsed_time.nil?
return render json: { error: 'Invalid timestamp format' }, status: :unprocessable_entity return render_timer_validation_error('Invalid timestamp format')
elsif timer_mode != 'countup' && !parsed_time.future? elsif timer_mode != 'countup' && !parsed_time.future?
return render json: { error: 'Timestamp must be in the future for countdown mode' }, return render_timer_validation_error('Timestamp must be in the future for countdown mode')
status: :unprocessable_entity
end end
params[:timestamp] = parsed_time params[:timestamp] = parsed_time
params[:timer_mode] = timer_mode.presence || 'countdown' params[:timer_mode] = timer_mode.presence || 'countdown'
rescue ArgumentError rescue ArgumentError
render json: { error: 'Invalid timestamp format' }, status: :unprocessable_entity render_timer_validation_error('Invalid timestamp format')
end end
elsif timer_mode == 'countup'
params[:timestamp] = Time.zone.now def normalize_timer_reason_params
elsif timer_mode.present? params[:timer_reason] = params[:timer_reason].presence
render json: { error: 'timer_mode requires timestamp, timestamp_seconds, or clear_timer' }, params[:timer_reason_text] = timer_reason_text_param
status: :unprocessable_entity
else
render json: { error: 'timestamp, timestamp_seconds, or clear_timer is required' },
status: :unprocessable_entity
end 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
end end

View File

@ -8,12 +8,6 @@ class VersionController < ApplicationController
private private
def git_commit_sha def git_commit_sha
ENV['GIT_COMMIT_SHA'].presence || local_git_commit_sha || 'unknown' ENV['GIT_COMMIT_SHA'].presence || 'aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa'
end
def local_git_commit_sha
Rails.root.join('.git').exist? ? `git rev-parse HEAD`.strip.presence : nil
rescue StandardError
nil
end end
end end

View File

@ -1,7 +1,7 @@
# frozen_string_literal: true # frozen_string_literal: true
class Match < ApplicationRecord 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 } undecided: 5 }
belongs_to :stage, optional: true belongs_to :stage, optional: true

View File

@ -1,7 +1,7 @@
# frozen_string_literal: true # frozen_string_literal: true
class Stage < ApplicationRecord 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 belongs_to :tournament
has_many :matches, dependent: :destroy has_many :matches, dependent: :destroy

View File

@ -2,6 +2,8 @@
class Team < ApplicationRecord class Team < ApplicationRecord
belongs_to :tournament, optional: true 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 :group_scores, dependent: :destroy
has_many :match_scores, dependent: :destroy has_many :match_scores, dependent: :destroy
has_many :bets, dependent: :destroy has_many :bets, dependent: :destroy
@ -9,5 +11,5 @@ class Team < ApplicationRecord
validates :name, presence: true validates :name, presence: true
delegate :owner, to: :tournament delegate :owner, to: :tournament, allow_nil: true
end end

View File

@ -0,0 +1,6 @@
# frozen_string_literal: true
class TeamPlayer < ApplicationRecord
belongs_to :team
belongs_to :player, class_name: 'User'
end

View File

@ -1,26 +1,38 @@
# frozen_string_literal: true # frozen_string_literal: true
require 'securerandom' require 'securerandom'
require 'openssl'
class Tournament < ApplicationRecord 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 :user
belongs_to :owner, class_name: 'User', foreign_key: :user_id, inverse_of: :tournaments
has_many :teams, dependent: :destroy has_many :teams, dependent: :destroy
has_many :stages, dependent: :destroy has_many :stages, dependent: :destroy
has_many :beamers, dependent: :destroy has_many :beamers, dependent: :destroy
has_many :team_action_lists, dependent: :destroy has_many :team_action_lists, dependent: :destroy
has_many :transaction_log_entries, class_name: 'TournamentTransactionLogEntry', dependent: :destroy
validates :name, presence: true validates :name, presence: true
validates :code, presence: true, uniqueness: true validates :code, presence: true, uniqueness: true
validates :timer_mode, inclusion: { in: %w[countdown countup] }, allow_nil: true validates :timer_mode, inclusion: { in: TIMER_MODES }, allow_nil: true
validates :timer_reason, inclusion: { in: TIMER_REASONS }, allow_nil: true
validates :sync_target_url, format: URI::DEFAULT_PARSER.make_regexp(%w[http https]), allow_blank: true validates :sync_target_url, format: URI::DEFAULT_PARSER.make_regexp(%w[http https]), allow_blank: true
validates :website_url, format: URI::DEFAULT_PARSER.make_regexp(%w[http https]), allow_blank: true
validate :sync_configuration_is_complete validate :sync_configuration_is_complete
validate :playoff_teams_amount_is_positive_power_of_two validate :playoff_teams_amount_is_positive_power_of_two
validate :timer_reason_text_matches_reason
alias_attribute :owner, :user validate :ends_at_is_after_starts_at
after_initialize :generate_code after_initialize :generate_code
after_create_commit :ensure_default_beamer! after_create_commit :ensure_default_beamer!
before_validation :normalize_timer_reason
before_validation :normalize_metadata
before_validation :protect_follower_sync_token
before_validation :clear_follower_sync_token_when_disabling_read_only_mode before_validation :clear_follower_sync_token_when_disabling_read_only_mode
after_commit :broadcast_timer_state_change, if: :saved_change_to_timer_state? after_commit :broadcast_timer_state_change, if: :saved_change_to_timer_state?
@ -45,7 +57,27 @@ class Tournament < ApplicationRecord
end end
def sync_accepts_push? 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 end
private private
@ -75,15 +107,15 @@ class Tournament < ApplicationRecord
end end
def sync_configuration_blank? def sync_configuration_blank?
sync_target_url.blank? && sync_auth_token.blank? sync_target_url.blank? && !sync_auth_configured?
end end
def sync_configuration_complete? def sync_configuration_complete?
sync_target_url.present? && sync_auth_token.present? sync_target_url.present? && sync_auth_configured?
end end
def follower_sync_token_only? 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 end
def ensure_default_beamer! def ensure_default_beamer!
@ -95,13 +127,55 @@ class Tournament < ApplicationRecord
def clear_follower_sync_token_when_disabling_read_only_mode def clear_follower_sync_token_when_disabling_read_only_mode
return if read_only_mode? return if read_only_mode?
self.sync_auth_token_digest = nil
return if sync_target_url.present? return if sync_target_url.present?
self.sync_auth_token = nil self.sync_auth_token = nil
end 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? def saved_change_to_timer_state?
saved_change_to_timestamp? || saved_change_to_timer_mode? saved_change_to_timestamp? ||
saved_change_to_timer_mode? ||
saved_change_to_timer_reason? ||
saved_change_to_timer_reason_text?
end end
def broadcast_timer_state_change def broadcast_timer_state_change

View File

@ -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

View File

@ -11,5 +11,38 @@ class User < ApplicationRecord
validates :username, presence: true, uniqueness: { case_sensitive: false } validates :username, presence: true, uniqueness: { case_sensitive: false }
has_many :tournaments, dependent: :destroy 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 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 end

View File

@ -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

View File

@ -0,0 +1,9 @@
# frozen_string_literal: true
class AccountSummarySerializer < ApplicationSerializer
attributes :name, :username
def name
object.username
end
end

View File

@ -1,7 +1,7 @@
# frozen_string_literal: true # frozen_string_literal: true
class LiveMatchSerializer < ApplicationSerializer class LiveMatchSerializer < ApplicationSerializer
attributes :position, :state, :teams, :group, :stage attributes :position, :state, :location, :start_time, :notes, :teams, :group, :stage
has_many :match_scores has_many :match_scores

View File

@ -1,7 +1,7 @@
# frozen_string_literal: true # frozen_string_literal: true
class MatchSerializer < ApplicationSerializer class MatchSerializer < ApplicationSerializer
attributes :state, :position, :winner, :decider attributes :state, :position, :winner, :decider, :location, :start_time, :notes
def winner def winner
ActiveModelSerializers::SerializableResource.new(object.winner).as_json ActiveModelSerializers::SerializableResource.new(object.winner).as_json

View File

@ -1,5 +1,13 @@
# frozen_string_literal: true # frozen_string_literal: true
class SimpleTournamentSerializer < ApplicationSerializer 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 end

View File

@ -1,5 +1,9 @@
# frozen_string_literal: true # frozen_string_literal: true
class TeamSerializer < ApplicationSerializer class TeamSerializer < ApplicationSerializer
attributes :name attributes :name, :players
def players
object.players.map { |player| AccountSummarySerializer.new(player).as_json }
end
end end

View File

@ -5,6 +5,7 @@ class TournamentSerializer < SimpleTournamentSerializer
:instant_finalists_amount, :intermediate_round_participants_amount :instant_finalists_amount, :intermediate_round_participants_amount
attribute :read_only_mode, if: :sync_metadata_visible? attribute :read_only_mode, if: :sync_metadata_visible?
attribute :sync_target_url, 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? 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 # NEVER expose sync_auth_token anywhere - it should only ever be written to or checked against
@ -16,6 +17,8 @@ class TournamentSerializer < SimpleTournamentSerializer
end end
attribute :timer_mode attribute :timer_mode
attribute :timer_reason
attribute :timer_reason_text
attribute :sync_last_pushed_at, if: :sync_metadata_visible? do attribute :sync_last_pushed_at, if: :sync_metadata_visible? do
object.sync_last_pushed_at&.iso8601 object.sync_last_pushed_at&.iso8601
@ -31,6 +34,7 @@ class TournamentSerializer < SimpleTournamentSerializer
{ {
id: team.id, id: team.id,
name: team.name, name: team.name,
players: team.players.map { |player| AccountSummarySerializer.new(player).as_json },
advancing_from_group_stage: adv_teams.include?(team) advancing_from_group_stage: adv_teams.include?(team)
} }
end end
@ -39,4 +43,8 @@ class TournamentSerializer < SimpleTournamentSerializer
def sync_metadata_visible? def sync_metadata_visible?
scope.present? && scope == object.owner scope.present? && scope == object.owner
end end
def sync_auth_configured
object.sync_auth_configured?
end
end end

View File

@ -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

View File

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

View File

@ -0,0 +1,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

View File

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

View File

@ -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

View File

@ -22,8 +22,12 @@ class TournamentSnapshotBuilder
def build def build
payload = tournament.attributes.symbolize_keys.slice(*ROOT_ATTRIBUTES) 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[:timestamp] = tournament.timestamp&.iso8601
payload[:timer_mode] = tournament.timer_mode payload[:timer_mode] = tournament.timer_mode
payload[:timer_reason] = tournament.timer_reason
payload[:timer_reason_text] = tournament.timer_reason_text
payload[:sync_generated_at] = Time.current.iso8601(6) payload[:sync_generated_at] = Time.current.iso8601(6)
payload[:teams] = teams_payload payload[:teams] = teams_payload
payload[:beamers] = beamers_payload payload[:beamers] = beamers_payload

View File

@ -6,6 +6,8 @@ class TournamentSnapshotImporter
code code
description description
public public
location
website_url
playoff_teams_amount playoff_teams_amount
instant_finalists_amount instant_finalists_amount
intermediate_round_participants_amount intermediate_round_participants_amount
@ -67,8 +69,12 @@ class TournamentSnapshotImporter
def update_tournament! def update_tournament!
root_attrs = snapshot.slice(*ROOT_ATTRIBUTES) root_attrs = snapshot.slice(*ROOT_ATTRIBUTES)
root_attrs[:code] = tournament.code if code_taken_by_another_tournament?(root_attrs[:code]) root_attrs[:code] = tournament.code if code_taken_by_another_tournament?(root_attrs[:code])
root_attrs[: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[:timestamp] = parse_time(snapshot[:timestamp])
root_attrs[:timer_mode] = snapshot[:timer_mode] root_attrs[:timer_mode] = snapshot[:timer_mode]
root_attrs[:timer_reason] = snapshot[:timer_reason]
root_attrs[:timer_reason_text] = snapshot[:timer_reason_text]
root_attrs[:sync_source_tournament_id] = snapshot[:id] root_attrs[:sync_source_tournament_id] = snapshot[:id]
root_attrs[:sync_last_imported_snapshot_at] = snapshot_generated_at root_attrs[:sync_last_imported_snapshot_at] = snapshot_generated_at
tournament.update!(root_attrs) tournament.update!(root_attrs)
@ -137,6 +143,9 @@ class TournamentSnapshotImporter
match.state = attrs.fetch(:state) match.state = attrs.fetch(:state)
match.position = attrs[:position] match.position = attrs[:position]
match.decider = attrs[:decider] match.decider = attrs[:decider]
match.location = attrs[:location]
match.start_time = attrs[:start_time]
match.notes = attrs[:notes]
end end
end end

View File

@ -9,11 +9,17 @@ class TournamentSyncSchema
code code
description description
public public
location
starts_at
ends_at
website_url
playoff_teams_amount playoff_teams_amount
instant_finalists_amount instant_finalists_amount
intermediate_round_participants_amount intermediate_round_participants_amount
timestamp timestamp
timer_mode timer_mode
timer_reason
timer_reason_text
].freeze, ].freeze,
ignored: %w[ ignored: %w[
user_id user_id
@ -22,6 +28,7 @@ class TournamentSyncSchema
read_only_mode read_only_mode
sync_target_url sync_target_url
sync_auth_token sync_auth_token
sync_auth_token_digest
sync_source_tournament_id sync_source_tournament_id
sync_last_pushed_at sync_last_pushed_at
sync_last_push_error sync_last_push_error
@ -45,7 +52,7 @@ class TournamentSyncSchema
ignored: %w[created_at updated_at sync_source_id].freeze ignored: %w[created_at updated_at sync_source_id].freeze
}.freeze, }.freeze,
Match => { 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 ignored: %w[created_at updated_at sync_source_id].freeze
}.freeze, }.freeze,
MatchScore => { MatchScore => {

View File

@ -7,7 +7,9 @@ class TournamentTimerBroadcastPayload
type: 'timer.updated', type: 'timer.updated',
tournament_id: tournament.id, tournament_id: tournament.id,
timestamp: tournament.timestamp&.iso8601, timestamp: tournament.timestamp&.iso8601,
timer_mode: tournament.timer_mode timer_mode: tournament.timer_mode,
timer_reason: tournament.timer_reason,
timer_reason_text: tournament.timer_reason_text
} }
end end
end end

View File

@ -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

View File

@ -36,6 +36,7 @@ module TurniereBackend
config.api_only = true config.api_only = true
config.action_cable.disable_request_forgery_protection = true config.action_cable.disable_request_forgery_protection = true
config.action_cable.allowed_request_origins = [%r{\Ahttps?://.*\z}] config.action_cable.allowed_request_origins = [%r{\Ahttps?://.*\z}]
config.active_storage.variant_processor = :disabled
config.middleware.insert_before 0, Rack::Cors do config.middleware.insert_before 0, Rack::Cors do
allow do allow do
@ -49,8 +50,5 @@ module TurniereBackend
config.action_mailer.default_options = { config.action_mailer.default_options = {
from: 'noreply@turnie.re' from: 'noreply@turnie.re'
} }
# Active Record
config.active_record.legacy_connection_handling = false
end end
end end

View File

@ -6,7 +6,7 @@
# #
default: &default default: &default
adapter: sqlite3 adapter: sqlite3
pool: <%= ENV.fetch("RAILS_MAX_THREADS") { 5 } %> pool: <%= ENV.fetch("RAILS_DB_POOL", ENV.fetch("RAILS_MAX_THREADS", 5)) %>
timeout: 5000 timeout: 5000
development: development:
@ -21,10 +21,12 @@ test:
database: db/test.sqlite3 database: db/test.sqlite3
production: &production 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) %> username: <%= ENV.fetch("POSTGRES_USERNAME", nil) %>
password: <%= ENV.fetch("POSTGRES_PASSWORD", 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: beta:
# just use the production settings # just use the production settings

View File

@ -79,12 +79,16 @@ Rails.application.configure do
# Mail # Mail
config.action_mailer.raise_delivery_errors = true config.action_mailer.raise_delivery_errors = true
config.action_mailer.default_url_options = { host: 'api.turnie.re' } 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.delivery_method = :mailgun
config.action_mailer.mailgun_settings = { config.action_mailer.mailgun_settings = {
api_key: ENV.fetch('MAILGUN_API_KEY'), api_key: ENV.fetch('MAILGUN_API_KEY'),
domain: ENV.fetch('MAILGUN_DOMAIN'), domain: ENV.fetch('MAILGUN_DOMAIN'),
api_host: 'api.eu.mailgun.net' # Uncomment this line for EU region domains api_host: 'api.eu.mailgun.net' # Uncomment this line for EU region domains
} }
end
# Logging to STDOUT (running in docker by default) # Logging to STDOUT (running in docker by default)
config.logger = Logger.new($stdout) config.logger = Logger.new($stdout)

View File

@ -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)

View File

@ -48,5 +48,6 @@ DeviseTokenAuth.setup do |config|
# do so by enabling this flag. NOTE: This feature is highly experimental! # do so by enabling this flag. NOTE: This feature is highly experimental!
# config.enable_standard_devise_support = false # config.enable_standard_devise_support = false
config.bypass_sign_in = false
config.default_confirm_success_url = 'https://turnie.re' config.default_confirm_success_url = 'https://turnie.re'
end end

View File

@ -6,8 +6,9 @@
# the maximum value specified for Puma. Default is set to 5 threads for minimum # 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. # and maximum; this matches the default thread size of Active Record.
# #
threads_count = ENV.fetch('RAILS_MAX_THREADS', 5) max_threads_count = ENV.fetch('RAILS_MAX_THREADS', 5)
threads threads_count, threads_count 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. # 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. # 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. # Specifies the number of `workers` to boot in clustered mode.
# Workers are forked webserver processes. If using threads and workers together # 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 # Workers do not work on JRuby or Windows (both of which do not support
# processes). # processes).
# #
# workers ENV.fetch("WEB_CONCURRENCY") { 2 } workers ENV.fetch('WEB_CONCURRENCY', 2)
# Use the `preload_app!` method when specifying a `workers` number. # Use the `preload_app!` method when specifying a `workers` number.
# This directive tells Puma to first boot the application and load code # This directive tells Puma to first boot the application and load code
# before forking the application. This takes advantage of Copy On Write # before forking the application. This takes advantage of Copy On Write
# process behavior so workers use less memory. # process behavior so workers use less memory.
# #
# preload_app! preload_app!
# Allow puma to be restarted by `rails restart` command. # Allow puma to be restarted by `rails restart` command.
plugin :tmp_restart plugin :tmp_restart

View File

@ -19,12 +19,17 @@ Rails.application.routes.draw do
end end
resources :stages, only: %i[show update] resources :stages, only: %i[show update]
resources :teams, only: %i[show update] resources :teams, only: %i[show update]
resources :accounts, only: %i[show]
resources :team_action_items, only: %i[update] resources :team_action_items, only: %i[update]
resources :tournaments do resources :tournaments do
resources :team_action_lists, only: %i[create] resources :team_action_lists, only: %i[create]
patch 'team_action_lists/:key/teams/:team_id', to: 'team_action_lists#update_item' patch 'team_action_lists/:key/teams/:team_id', to: 'team_action_lists#update_item'
resources :statistics, only: %i[index] 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 resources :beamers, only: %i[index show create update destroy] do
collection do collection do
get :by_name get :by_name
@ -32,12 +37,16 @@ Rails.application.routes.draw do
end end
member do member do
get :timer get :timer
get :transaction_log, to: 'tournament_transaction_log_entries#index'
patch :timer, action: :set_timer patch :timer, action: :set_timer
patch :sync_state patch :sync_state
patch :start_next_group_matches
post :test_sync post :test_sync
delete :playoffs, action: :destroy_playoffs
end end
end end
resources :match_scores, only: %i[show update] resources :match_scores, only: %i[show update]
get 'healthz', to: 'health#index' get 'healthz', to: 'health#index'
get 'metrics', to: 'metrics#index'
get 'version', to: 'version#show' get 'version', to: 'version#show'
end end

2
config/sidekiq.yml Normal file
View File

@ -0,0 +1,2 @@
---
:concurrency: <%= ENV.fetch("SIDEKIQ_CONCURRENCY", 5) %>

View File

@ -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

View File

@ -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

View File

@ -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

View File

@ -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

View File

@ -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

View File

@ -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

View File

@ -10,96 +10,99 @@
# #
# It's strongly recommended that you check this file into your version control system. # It's strongly recommended that you check this file into your version control system.
ActiveRecord::Schema[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| 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.string "display_state", default: "during_game", null: false
t.boolean "is_default", default: false t.boolean "is_default", default: false
t.integer "tournament_id", null: false t.string "name", null: false
t.datetime "created_at", null: false
t.datetime "updated_at", null: false
t.integer "sync_source_id" 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"], name: "index_beamers_on_tournament_id_and_name", unique: true
t.index ["tournament_id"], name: "index_beamers_on_tournament_id" t.index ["tournament_id"], name: "index_beamers_on_tournament_id"
end end
create_table "bets", force: :cascade do |t| 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 "match_id", null: false
t.integer "team_id" t.integer "team_id"
t.datetime "created_at", precision: nil, null: false
t.datetime "updated_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 ["match_id"], name: "index_bets_on_match_id"
t.index ["team_id"], name: "index_bets_on_team_id" t.index ["team_id"], name: "index_bets_on_team_id"
t.index ["user_id"], name: "index_bets_on_user_id" t.index ["user_id"], name: "index_bets_on_user_id"
end end
create_table "group_scores", force: :cascade do |t| 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 "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 "position", default: 0
t.integer "received_points", default: 0
t.integer "scored_points", default: 0
t.integer "sync_source_id" 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 ["group_id"], name: "index_group_scores_on_group_id"
t.index ["team_id"], name: "index_group_scores_on_team_id" t.index ["team_id"], name: "index_group_scores_on_team_id"
end end
create_table "groups", force: :cascade do |t| create_table "groups", force: :cascade do |t|
t.datetime "created_at", precision: nil, null: false
t.integer "number" t.integer "number"
t.integer "stage_id", null: false 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.integer "sync_source_id"
t.datetime "updated_at", precision: nil, null: false
t.index ["stage_id"], name: "index_groups_on_stage_id" t.index ["stage_id"], name: "index_groups_on_stage_id"
end end
create_table "match_scores", force: :cascade do |t| 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 "created_at", precision: nil, null: false
t.datetime "updated_at", precision: nil, null: false
t.integer "hidden_points", default: 0, 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 "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 ["match_id"], name: "index_match_scores_on_match_id"
t.index ["team_id"], name: "index_match_scores_on_team_id" t.index ["team_id"], name: "index_match_scores_on_team_id"
end end
create_table "matches", force: :cascade do |t| 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 "position"
t.integer "stage_id" t.integer "stage_id"
t.integer "group_id" t.string "start_time"
t.datetime "created_at", precision: nil, null: false t.integer "state", default: 0
t.datetime "updated_at", precision: nil, null: false
t.boolean "decider", default: false, null: false
t.integer "sync_source_id" 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 ["group_id"], name: "index_matches_on_group_id"
t.index ["stage_id"], name: "index_matches_on_stage_id" t.index ["stage_id"], name: "index_matches_on_stage_id"
end end
create_table "stages", force: :cascade do |t| create_table "stages", force: :cascade do |t|
t.datetime "created_at", precision: nil, null: false
t.integer "level" t.integer "level"
t.integer "state", default: 0 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 "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" t.index ["tournament_id"], name: "index_stages_on_tournament_id"
end end
create_table "team_action_items", force: :cascade do |t| 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.boolean "completed", default: false, null: false
t.datetime "completed_at" t.datetime "completed_at"
t.integer "sync_source_id"
t.datetime "created_at", null: false 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.datetime "updated_at", null: false
t.index ["sync_source_id"], name: "index_team_action_items_on_sync_source_id" t.index ["sync_source_id"], name: "index_team_action_items_on_sync_source_id"
t.index ["team_action_list_id", "team_id"], name: "index_team_action_items_on_team_action_list_id_and_team_id", unique: true t.index ["team_action_list_id", "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 end
create_table "team_action_lists", force: :cascade do |t| create_table "team_action_lists", force: :cascade do |t|
t.integer "tournament_id", null: false
t.string "name", null: false
t.string "key", null: false
t.string "action_name", null: false t.string "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.string "source", null: false
t.integer "sync_source_id" t.integer "sync_source_id"
t.datetime "created_at", null: false t.integer "tournament_id", null: false
t.datetime "updated_at", null: false t.datetime "updated_at", null: false
t.index ["sync_source_id"], name: "index_team_action_lists_on_sync_source_id" t.index ["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", "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" t.index ["tournament_id"], name: "index_team_action_lists_on_tournament_id"
end 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| create_table "teams", force: :cascade do |t|
t.string "name"
t.integer "tournament_id"
t.datetime "created_at", precision: nil, null: false 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 "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" t.index ["tournament_id"], name: "index_teams_on_tournament_id"
end end
create_table "tournament_sync_queue_entries", force: :cascade do |t| 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.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 "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.datetime "updated_at", null: false
t.index ["next_attempt_at"], name: "index_tournament_sync_queue_entries_on_next_attempt_at" 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 ["status"], name: "index_tournament_sync_queue_entries_on_status"
t.index ["tournament_id"], name: "index_tournament_sync_queue_entries_on_tournament_id", unique: true t.index ["tournament_id"], name: "index_tournament_sync_queue_entries_on_tournament_id", unique: true
end 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| create_table "tournaments", force: :cascade do |t|
t.string "name", null: false
t.string "code", null: false t.string "code", null: false
t.datetime "created_at", precision: nil, null: false
t.string "description" t.string "description"
t.boolean "public", default: true t.datetime "ends_at"
t.integer "playoff_teams_amount", default: 0
t.integer "instant_finalists_amount", default: 0 t.integer "instant_finalists_amount", default: 0
t.integer "intermediate_round_participants_amount", default: 0 t.integer "intermediate_round_participants_amount", default: 0
t.integer "user_id", null: false t.string "location"
t.datetime "created_at", precision: nil, null: false t.string "name", null: false
t.datetime "updated_at", precision: nil, null: false t.integer "playoff_teams_amount", default: 0
t.datetime "timestamp" t.boolean "public", default: true
t.boolean "read_only_mode", default: false, null: false t.boolean "read_only_mode", default: false, null: false
t.string "sync_target_url" t.datetime "starts_at"
t.string "sync_auth_token" t.string "sync_auth_token"
t.integer "sync_source_tournament_id" t.string "sync_auth_token_digest"
t.datetime "sync_last_pushed_at"
t.string "sync_last_push_error"
t.datetime "sync_last_imported_snapshot_at" 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_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 ["code"], name: "index_tournaments_on_code", unique: true
t.index ["user_id"], name: "index_tournaments_on_user_id" t.index ["user_id"], name: "index_tournaments_on_user_id"
end end
create_table "users", force: :cascade do |t| 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.boolean "allow_password_change", default: false
t.datetime "remember_created_at", precision: nil t.datetime "confirmation_sent_at", precision: nil
t.integer "sign_in_count", default: 0, null: false
t.datetime "current_sign_in_at", precision: nil
t.datetime "last_sign_in_at", precision: nil
t.string "current_sign_in_ip"
t.string "last_sign_in_ip"
t.string "confirmation_token" t.string "confirmation_token"
t.datetime "confirmed_at", precision: nil 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 "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.datetime "updated_at", precision: nil, null: false
t.string "username"
t.index ["confirmation_token"], name: "index_users_on_confirmation_token", unique: true t.index ["confirmation_token"], name: "index_users_on_confirmation_token", unique: true
t.index ["email"], name: "index_users_on_email", unique: true t.index ["email"], name: "index_users_on_email", unique: true
t.index ["provider"], name: "index_users_on_provider" 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", "team_action_lists", on_delete: :cascade
add_foreign_key "team_action_items", "teams", 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_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 "teams", "tournaments", on_delete: :cascade
add_foreign_key "tournament_sync_queue_entries", "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 add_foreign_key "tournaments", "users", on_delete: :cascade
end end

View File

@ -1,16 +1,22 @@
# frozen_string_literal: true # frozen_string_literal: true
require 'faker' 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
user = User.create! username: Faker::Internet.username, email: Faker::Internet.email, password: Faker::Internet.password @teams = []
tournament = user.tournaments.create! name: Faker::Creature::Dog.name 16.times do
@teams = []
16.times do
team = tournament.teams.create! name: Faker::Movies::HarryPotter.character team = tournament.teams.create! name: Faker::Movies::HarryPotter.character
@teams << team @teams << team
end end
4.times do |i| 4.times do |i|
stage = tournament.stages.create! stage = tournament.stages.create!
stage.level = i stage.level = i
matches_amount = 2**i matches_amount = 2**i
@ -23,4 +29,6 @@ end
match.save! match.save!
end end
stage.save! stage.save!
end
end
end end

350
doc/account_profiles_api.md Normal file
View File

@ -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.

View File

@ -24,6 +24,7 @@ Create empty tournament with:
- `sync_auth_token: <shared secret>` - `sync_auth_token: <shared secret>`
Do not send `teams` payload for follower. Backend allows empty read only follower tournament creation. 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 ### 2. Create normal leader tournament on local backend
@ -178,9 +179,9 @@ Owner-facing frontend can expose:
Recommended owner UX: Recommended owner UX:
1. create remote follower first 1. generate a shared token outside the backend
2. copy remote `sync_auth_token` 2. create remote follower with that `sync_auth_token`
3. paste remote `sync_state` URL into leader 3. paste same token and remote `sync_state` URL into leader
4. save leader sync config 4. save leader sync config
5. show last push time / error state 5. show last push time / error state
6. allow explicit follower takeover by disabling `read_only_mode` 6. allow explicit follower takeover by disabling `read_only_mode`

39
doc/load_tests.md Normal file
View File

@ -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.

86
doc/match_metadata_api.md Normal file
View File

@ -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.

20
doc/metrics.md Normal file
View File

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

View File

@ -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.

View File

@ -15,6 +15,8 @@ Frontend should treat timer state as:
- no timer: `timestamp = null`, `timer_mode = null` - no timer: `timestamp = null`, `timer_mode = null`
- countdown: `timer_mode = "countdown"` - countdown: `timer_mode = "countdown"`
- countup: `timer_mode = "countup"` - countup: `timer_mode = "countup"`
- optional reason: `timer_reason` may be `tournament_start`, `playoff_start`, `match_end`, or `custom`
- custom reason: `timer_reason = "custom"` and `timer_reason_text` contains the display text
## Read Flow ## Read Flow
@ -31,7 +33,9 @@ Response shape:
```json ```json
{ {
"timestamp": "2026-04-24T10:15:00Z", "timestamp": "2026-04-24T10:15:00Z",
"timer_mode": "countdown" "timer_mode": "countdown",
"timer_reason": "tournament_start",
"timer_reason_text": null
} }
``` ```
@ -40,6 +44,13 @@ Possible values:
- `timer_mode = "countdown"` - `timer_mode = "countdown"`
- `timer_mode = "countup"` - `timer_mode = "countup"`
- `timer_mode = null` - `timer_mode = null`
- `timer_reason = "tournament_start"`
- `timer_reason = "playoff_start"`
- `timer_reason = "match_end"`
- `timer_reason = "custom"`
- `timer_reason = null`
`timer_reason_text` is only used when `timer_reason = "custom"`.
## Meaning Of `timestamp` ## Meaning Of `timestamp`
@ -103,7 +114,8 @@ Request:
```json ```json
{ {
"timestamp_seconds": 120 "timestamp_seconds": 120,
"timer_reason": "tournament_start"
} }
``` ```
@ -120,7 +132,8 @@ Request:
```json ```json
{ {
"timestamp": "2026-04-24T10:15:00Z" "timestamp": "2026-04-24T10:15:00Z",
"timer_reason": "playoff_start"
} }
``` ```
@ -129,7 +142,9 @@ Optional explicit mode:
```json ```json
{ {
"timestamp": "2026-04-24T10:15:00Z", "timestamp": "2026-04-24T10:15:00Z",
"timer_mode": "countdown" "timer_mode": "countdown",
"timer_reason": "custom",
"timer_reason_text": "Opening ceremony ran long"
} }
``` ```
@ -139,7 +154,8 @@ Request:
```json ```json
{ {
"timer_mode": "countup" "timer_mode": "countup",
"timer_reason": "match_end"
} }
``` ```
@ -157,7 +173,9 @@ Backend also accepts explicit timestamp with countup mode:
```json ```json
{ {
"timestamp": "2026-04-24T10:00:00Z", "timestamp": "2026-04-24T10:00:00Z",
"timer_mode": "countup" "timer_mode": "countup",
"timer_reason": "custom",
"timer_reason_text": "Manual overtime"
} }
``` ```
@ -167,6 +185,39 @@ Useful for:
- rehydrating synced follower state - rehydrating synced follower state
- advanced admin tooling - 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 ### Clear timer
Request: Request:
@ -179,10 +230,12 @@ Request:
Behavior: Behavior:
- backend clears both fields - backend clears all timer fields
- response returns: - response returns:
- `timestamp = null` - `timestamp = null`
- `timer_mode = null` - `timer_mode = null`
- `timer_reason = null`
- `timer_reason_text = null`
## Validation Rules ## Validation Rules
@ -194,6 +247,11 @@ Backend rejects:
- `timestamp_seconds` together with `timer_mode = "countup"` - `timestamp_seconds` together with `timer_mode = "countup"`
- `timer_mode` by itself unless it is `countup` - `timer_mode` by itself unless it is `countup`
- `clear_timer` mixed with timer values - `clear_timer` mixed with timer values
- invalid `timer_reason`
- 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. Countdown timestamps must be future timestamps.
Countup timestamps may be current or past timestamps. Countup timestamps may be current or past timestamps.
@ -223,7 +281,9 @@ Payload:
"type": "timer.updated", "type": "timer.updated",
"tournament_id": 123, "tournament_id": 123,
"timestamp": "2026-04-24T10:15:00Z", "timestamp": "2026-04-24T10:15:00Z",
"timer_mode": "countdown" "timer_mode": "countdown",
"timer_reason": "tournament_start",
"timer_reason_text": null
} }
``` ```
@ -243,7 +303,7 @@ Frontend rule:
Recommended flow: Recommended flow:
1. Load tournament via `GET /tournaments/:id` 1. Load tournament via `GET /tournaments/:id`
2. Read `timer_mode` and `timestamp` 2. Read `timer_mode`, `timestamp`, `timer_reason`, and `timer_reason_text`
3. Open ActionCable subscription for same tournament id 3. Open ActionCable subscription for same tournament id
4. Replace local timer state whenever `timer.updated` arrives 4. Replace local timer state whenever `timer.updated` arrives
5. Derive displayed time from `Date.now()` plus stored backend timestamp 5. Derive displayed time from `Date.now()` plus stored backend timestamp
@ -252,9 +312,11 @@ Recommended client state:
```ts ```ts
type TimerState = type TimerState =
| { mode: null; timestampIso: null } | { mode: null; timestampIso: null; reason: null; reasonText: null }
| { mode: "countdown"; timestampIso: string } | { mode: "countdown"; timestampIso: string; reason: TimerReason | null; reasonText: string | null }
| { mode: "countup"; timestampIso: string }; | { mode: "countup"; timestampIso: string; reason: TimerReason | null; reasonText: string | null };
type TimerReason = "tournament_start" | "playoff_start" | "match_end" | "custom";
``` ```
Suggested derivation logic: Suggested derivation logic:
@ -283,7 +345,7 @@ Recommended rendering:
Recommended local ticking: Recommended local ticking:
- keep backend state as timestamp + mode - keep backend state as timestamp + mode + reason
- update rendered value with local interval - update rendered value with local interval
- do not poll backend every second - do not poll backend every second
@ -300,7 +362,7 @@ Timer state is included in tournament sync snapshots.
That means: That means:
- leader timer changes propagate to follower - leader timer changes propagate to follower
- follower tournament exposes same `timestamp` / `timer_mode` - follower tournament exposes same `timestamp` / `timer_mode` / `timer_reason` / `timer_reason_text`
- follower websocket subscribers receive `timer.updated` - follower websocket subscribers receive `timer.updated`
- follower still rejects timer writes while read only - follower still rejects timer writes while read only
@ -311,6 +373,7 @@ Implemented coverage:
- API surface timer lifecycle: - API surface timer lifecycle:
- countdown - countdown
- countup - countup
- reason-only set / change / remove
- clear - clear
- validation - validation
- [spec/e2e/http/api_surface_spec.rb](../spec/e2e/http/api_surface_spec.rb) - [spec/e2e/http/api_surface_spec.rb](../spec/e2e/http/api_surface_spec.rb)
@ -321,5 +384,5 @@ Implemented coverage:
Current note: Current note:
- single-backend HTTP E2E timer specs pass - single-backend and local dual-server HTTP E2E timer specs pass
- follower blackbox task still has existing Docker/network issue in follow setup, so production-style follow stack verification is not green yet - follower blackbox task still has existing Docker/network issue in follow setup, so production-style follow stack verification is not green yet

View File

@ -70,6 +70,8 @@ Payload:
"intermediate_round_participants_amount": 0, "intermediate_round_participants_amount": 0,
"timestamp": null, "timestamp": null,
"timer_mode": null, "timer_mode": null,
"timer_reason": null,
"timer_reason_text": null,
"owner_username": "owner", "owner_username": "owner",
"stages": [], "stages": [],
"teams": [], "teams": [],

View File

@ -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

View File

@ -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.

View File

@ -37,12 +37,19 @@ services:
source-postgres: source-postgres:
condition: service_healthy condition: service_healthy
environment: 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_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_USERNAME: ${TURNIERE_BLACKBOX_POSTGRES_USER:-turniere}
POSTGRES_PASSWORD: ${TURNIERE_BLACKBOX_POSTGRES_PASSWORD:-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} 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_API_KEY: ${TURNIERE_BLACKBOX_MAILGUN_API_KEY:-blackbox-test-api-key}
MAILGUN_DOMAIN: ${TURNIERE_BLACKBOX_MAILGUN_DOMAIN:-blackbox.example.com} 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} TOURNAMENT_SYNC_HTTP_TIMEOUT_SECONDS: ${TURNIERE_BLACKBOX_TOURNAMENT_SYNC_HTTP_TIMEOUT_SECONDS:-3}
RAILS_LOG_TO_STDOUT: "1" RAILS_LOG_TO_STDOUT: "1"
RAILS_SERVE_STATIC_FILES: "1" RAILS_SERVE_STATIC_FILES: "1"
@ -58,12 +65,19 @@ services:
follower-postgres: follower-postgres:
condition: service_healthy condition: service_healthy
environment: 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_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_USERNAME: ${TURNIERE_BLACKBOX_POSTGRES_USER:-turniere}
POSTGRES_PASSWORD: ${TURNIERE_BLACKBOX_POSTGRES_PASSWORD:-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} 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_API_KEY: ${TURNIERE_BLACKBOX_MAILGUN_API_KEY:-blackbox-test-api-key}
MAILGUN_DOMAIN: ${TURNIERE_BLACKBOX_MAILGUN_DOMAIN:-blackbox.example.com} 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} TOURNAMENT_SYNC_HTTP_TIMEOUT_SECONDS: ${TURNIERE_BLACKBOX_TOURNAMENT_SYNC_HTTP_TIMEOUT_SECONDS:-3}
RAILS_LOG_TO_STDOUT: "1" RAILS_LOG_TO_STDOUT: "1"
RAILS_SERVE_STATIC_FILES: "1" RAILS_SERVE_STATIC_FILES: "1"

View File

@ -19,12 +19,19 @@ services:
postgres: postgres:
condition: service_healthy condition: service_healthy
environment: 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_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_USERNAME: ${TURNIERE_BLACKBOX_POSTGRES_USER:-turniere}
POSTGRES_PASSWORD: ${TURNIERE_BLACKBOX_POSTGRES_PASSWORD:-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} 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_API_KEY: ${TURNIERE_BLACKBOX_MAILGUN_API_KEY:-blackbox-test-api-key}
MAILGUN_DOMAIN: ${TURNIERE_BLACKBOX_MAILGUN_DOMAIN:-blackbox.example.com} 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_LOG_TO_STDOUT: "1"
RAILS_SERVE_STATIC_FILES: "1" RAILS_SERVE_STATIC_FILES: "1"
ports: ports:

View File

@ -80,7 +80,7 @@ module TurniereE2E
login!(**@login_credentials) if refresh_session && should_refresh_session?(method, path) login!(**@login_credentials) if refresh_session && should_refresh_session?(method, path)
uri = URI.parse("#{base_url}#{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 request_class = case method
when :get then Net::HTTP::Get when :get then Net::HTTP::Get

338
e2e/lib/load_test_runner.rb Normal file
View File

@ -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

View File

@ -12,7 +12,7 @@ module TurniereE2E
attr_reader :client attr_reader :client
def initialize(base_url:, email: nil, password: nil, username: nil) def initialize(base_url:, email: nil, password: nil, username: nil)
@client = ApiClient.new(base_url:) @client = ApiClient.new(base_url: base_url)
@email = email @email = email
@password = password @password = password
@username = username @username = username
@ -30,31 +30,25 @@ module TurniereE2E
timer_response = patch!( timer_response = patch!(
"/tournaments/#{tournament.fetch(:id)}/timer", "/tournaments/#{tournament.fetch(:id)}/timer",
body: { timestamp_seconds: 600 } body: { timestamp_seconds: 600, timer_reason: 'tournament_start' }
) )
checkpoints[:timer_set] = { timestamp: timer_response.fetch(:timestamp) } checkpoints[:timer_set] = {
if stop_at == :timer_set timestamp: timer_response.fetch(:timestamp),
return result(session:, tournament:, checkpoints:, timer_reason: timer_response.fetch(:timer_reason)
stop_at:) }
end return checkpoint_result(session, tournament, checkpoints, stop_at) if stop_at == :timer_set
upcoming_matches = get!( upcoming_matches = get!(
"/tournaments/#{tournament.fetch(:id)}/matches", "/tournaments/#{tournament.fetch(:id)}/matches",
params: { state: 'upcoming' } params: { state: 'upcoming' }
) )
checkpoints[:upcoming_group_matches] = { match_ids: upcoming_matches.map { |match| match.fetch(:id) } } checkpoints[:upcoming_group_matches] = { match_ids: upcoming_matches.map { |match| match.fetch(:id) } }
if stop_at == :upcoming_group_matches return checkpoint_result(session, tournament, checkpoints, stop_at) if stop_at == :upcoming_group_matches
return result(session:, tournament:, checkpoints:,
stop_at:)
end
finish_group_stage_matches!(tournament) finish_group_stage_matches!(tournament)
tournament = fetch_tournament!(tournament.fetch(:id)) tournament = fetch_tournament!(tournament.fetch(:id))
checkpoints[:groups_finished] = summarize_tournament(tournament) checkpoints[:groups_finished] = summarize_tournament(tournament)
if stop_at == :groups_finished return checkpoint_result(session, tournament, checkpoints, stop_at) if stop_at == :groups_finished
return result(session:, tournament:, checkpoints:,
stop_at:)
end
statistics = get!("/tournaments/#{tournament.fetch(:id)}/statistics") statistics = get!("/tournaments/#{tournament.fetch(:id)}/statistics")
checkpoints[:statistics] = { checkpoints[:statistics] = {
@ -66,27 +60,21 @@ module TurniereE2E
finish_stage!(group_stage_of!(tournament).fetch(:id)) finish_stage!(group_stage_of!(tournament).fetch(:id))
tournament = fetch_tournament!(tournament.fetch(:id)) tournament = fetch_tournament!(tournament.fetch(:id))
checkpoints[:playoffs_created] = summarize_tournament(tournament) checkpoints[:playoffs_created] = summarize_tournament(tournament)
if stop_at == :playoffs_created return checkpoint_result(session, tournament, checkpoints, stop_at) if stop_at == :playoffs_created
return result(session:, tournament:, checkpoints:,
stop_at:)
end
run_playoffs_until_before_final!(tournament.fetch(:id)) run_playoffs_until_before_final!(tournament.fetch(:id))
tournament = fetch_tournament!(tournament.fetch(:id)) tournament = fetch_tournament!(tournament.fetch(:id))
checkpoints[:before_final_finish_attempt] = summarize_tournament(tournament) checkpoints[:before_final_finish_attempt] = summarize_tournament(tournament)
if stop_at == :before_final_finish_attempt return checkpoint_result(session, tournament, checkpoints, stop_at) if stop_at == :before_final_finish_attempt
return result(session:, tournament:, checkpoints:,
stop_at:)
end
finish_attempt = finish_final_stage_matches!(tournament) finish_attempt = finish_final_stage_matches!(tournament)
tournament = fetch_tournament!(tournament.fetch(:id)) tournament = fetch_tournament!(tournament.fetch(:id))
checkpoints[:final_finish_attempted] = { checkpoints[:final_finish_attempted] = {
finish_attempt:, finish_attempt: finish_attempt,
tournament: summarize_tournament(tournament) tournament: summarize_tournament(tournament)
} }
result(session:, tournament:, checkpoints:, stop_at:) result(session: session, tournament: tournament, checkpoints: checkpoints, stop_at: stop_at)
end end
def run_group_stage_intermediate_round(stop_at: :final_finish_attempted) def run_group_stage_intermediate_round(stop_at: :final_finish_attempted)
@ -99,8 +87,8 @@ module TurniereE2E
created: summarize_tournament(tournament) created: summarize_tournament(tournament)
} }
if stop_at == :created if stop_at == :created
return result(session:, tournament:, checkpoints:, return result(session: session, tournament: tournament, checkpoints: checkpoints,
stop_at:) stop_at: stop_at)
end end
finish_group_stage_matches!(tournament) finish_group_stage_matches!(tournament)
@ -108,44 +96,87 @@ module TurniereE2E
tournament = fetch_tournament!(tournament.fetch(:id)) tournament = fetch_tournament!(tournament.fetch(:id))
checkpoints[:playoffs_created] = summarize_tournament(tournament) checkpoints[:playoffs_created] = summarize_tournament(tournament)
if stop_at == :playoffs_created if stop_at == :playoffs_created
return result(session:, tournament:, checkpoints:, return result(session: session, tournament: tournament, checkpoints: checkpoints,
stop_at:) stop_at: stop_at)
end end
run_playoffs_until_before_final!(tournament.fetch(:id)) run_playoffs_until_before_final!(tournament.fetch(:id))
tournament = fetch_tournament!(tournament.fetch(:id)) tournament = fetch_tournament!(tournament.fetch(:id))
checkpoints[:before_final_finish_attempt] = summarize_tournament(tournament) checkpoints[:before_final_finish_attempt] = summarize_tournament(tournament)
if stop_at == :before_final_finish_attempt if stop_at == :before_final_finish_attempt
return result(session:, tournament:, checkpoints:, return result(session: session, tournament: tournament, checkpoints: checkpoints,
stop_at:) stop_at: stop_at)
end end
finish_attempt = finish_final_stage_matches!(tournament) finish_attempt = finish_final_stage_matches!(tournament)
tournament = fetch_tournament!(tournament.fetch(:id)) tournament = fetch_tournament!(tournament.fetch(:id))
checkpoints[:final_finish_attempted] = { checkpoints[:final_finish_attempted] = {
finish_attempt:, finish_attempt: finish_attempt,
tournament: summarize_tournament(tournament) 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 end
def run_playoff_only(team_count:, stop_at: :completed) def run_playoff_only(team_count:, stop_at: :completed)
session = register_session! session = register_session!
tournament = create_tournament!(create_playoff_only_payload(team_count:)) tournament = create_tournament!(create_playoff_only_payload(team_count: team_count))
checkpoints = { checkpoints = {
created: summarize_tournament(tournament) created: summarize_tournament(tournament)
} }
if stop_at == :created if stop_at == :created
return result(session:, tournament:, checkpoints:, return result(session: session, tournament: tournament, checkpoints: checkpoints,
stop_at:) stop_at: stop_at)
end end
run_playoffs_until_complete!(tournament.fetch(:id)) run_playoffs_until_complete!(tournament.fetch(:id))
tournament = fetch_tournament!(tournament.fetch(:id)) tournament = fetch_tournament!(tournament.fetch(:id))
checkpoints[:completed] = summarize_tournament(tournament) checkpoints[:completed] = summarize_tournament(tournament)
result(session:, tournament:, checkpoints:, stop_at:) result(session: session, tournament: tournament, checkpoints: checkpoints, stop_at: stop_at)
end end
def run_group_direct_comparison_tiebreak(case_name:, stop_at: :group_scores) def run_group_direct_comparison_tiebreak(case_name:, stop_at: :group_scores)
@ -158,8 +189,8 @@ module TurniereE2E
created: summarize_tournament(tournament) created: summarize_tournament(tournament)
} }
if stop_at == :created if stop_at == :created
return result(session:, tournament:, checkpoints:, return result(session: session, tournament: tournament, checkpoints: checkpoints,
stop_at:) stop_at: stop_at)
end end
group = group_stage_of!(tournament).fetch(:groups).first group = group_stage_of!(tournament).fetch(:groups).first
@ -172,16 +203,68 @@ module TurniereE2E
} }
tournament = fetch_tournament!(tournament.fetch(:id)) 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 end
def run_group_stage_render_profile(group_count:, teams_per_group:, playoff_teams_amount:, stop_at: :profiled_show) def run_group_stage_render_profile(group_count:, teams_per_group:, playoff_teams_amount:, stop_at: :profiled_show)
session = register_session! session = register_session!
tournament = create_tournament!( tournament = create_tournament!(
create_group_stage_payload( create_group_stage_payload(
group_count:, group_count: group_count,
teams_per_group:, teams_per_group: teams_per_group,
playoff_teams_amount: playoff_teams_amount: playoff_teams_amount
) )
) )
@ -189,18 +272,18 @@ module TurniereE2E
created: summarize_tournament(tournament) created: summarize_tournament(tournament)
} }
if stop_at == :created if stop_at == :created
return result(session:, tournament:, checkpoints:, return result(session: session, tournament: tournament, checkpoints: checkpoints,
stop_at:) stop_at: stop_at)
end end
profiled_show = fetch_tournament_profile!(tournament.fetch(:id)) profiled_show = fetch_tournament_profile!(tournament.fetch(:id))
checkpoints[:profiled_show] = profiled_show 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 end
def wait_for_healthcheck!(timeout_seconds: 30) def wait_for_healthcheck!(timeout_seconds: 30)
started_at = Time.zone.now started_at = Process.clock_gettime(Process::CLOCK_MONOTONIC)
loop do loop do
begin begin
response = client.get('/healthz') response = client.get('/healthz')
@ -209,7 +292,8 @@ module TurniereE2E
nil nil
end 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 sleep 0.5
end end
@ -223,11 +307,11 @@ module TurniereE2E
unique = SecureRandom.hex(6) unique = SecureRandom.hex(6)
email = "e2e-#{unique}@example.com" email = "e2e-#{unique}@example.com"
username = "e2e-#{unique}" username = "e2e-#{unique}"
client.register!(email:, password: DEFAULT_PASSWORD, username:) client.register!(email: email, password: DEFAULT_PASSWORD, username: username)
return { email:, username: } if client.authenticated? return { email: email, username: username } if client.authenticated?
login_response = client.login!(email:, password: DEFAULT_PASSWORD) login_response = client.login!(email: email, password: DEFAULT_PASSWORD)
return { email:, username: } if login_response.fetch(:status) == 200 && client.authenticated? return { email: email, username: username } if login_response.fetch(:status) == 200 && client.authenticated?
raise ApiError.new( raise ApiError.new(
'authentication bootstrap failed; provide a confirmed E2E user via TURNIERE_E2E_EMAIL and TURNIERE_E2E_PASSWORD', 'authentication bootstrap failed; provide a confirmed E2E user via TURNIERE_E2E_EMAIL and TURNIERE_E2E_PASSWORD',
@ -236,6 +320,20 @@ module TurniereE2E
) )
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
def create_tournament!(payload) def create_tournament!(payload)
response = client.post('/tournaments', body: payload) response = client.post('/tournaments', body: payload)
expect_status!(response, 201, 'create tournament') expect_status!(response, 201, 'create tournament')
@ -249,19 +347,19 @@ module TurniereE2E
end end
def get!(path, params: nil) def get!(path, params: nil)
response = get_response!(path, params:) response = get_response!(path, params: params)
expect_status!(response, 200, "get #{path}") expect_status!(response, 200, "get #{path}")
response.fetch(:json) response.fetch(:json)
end end
def get_response!(path, params: nil) def get_response!(path, params: nil)
response = client.get(path, params:) response = client.get(path, params: params)
expect_status!(response, 200, "get #{path}") expect_status!(response, 200, "get #{path}")
response response
end end
def patch!(path, body:) def patch!(path, body:)
response = client.patch(path, body:) response = client.patch(path, body: body)
expect_status!(response, 200, "patch #{path}") expect_status!(response, 200, "patch #{path}")
response.fetch(:json) response.fetch(:json)
end end
@ -315,7 +413,7 @@ module TurniereE2E
refreshed_match = fetch_match!(match.fetch(:id)) refreshed_match = fetch_match!(match.fetch(:id))
refreshed_match.fetch(:match_scores).sort_by { |score| score.fetch(:id) }.each do |score| refreshed_match.fetch(:match_scores).sort_by { |score| score.fetch(:id) }.each do |score|
points = score.dig(:team, :name) == winner_name ? 10 : 1 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)}") expect_status!(updated_score, 200, "update match_score #{score.fetch(:id)}")
end end
@ -345,7 +443,7 @@ module TurniereE2E
points = points_by_team_name.fetch(team_name) do points = points_by_team_name.fetch(team_name) do
raise "missing score for #{team_name.inspect} in match #{match.fetch(:id)}" raise "missing score for #{team_name.inspect} in match #{match.fetch(:id)}"
end 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)}") expect_status!(updated_score, 200, "update match_score #{score.fetch(:id)}")
end end
@ -365,6 +463,36 @@ module TurniereE2E
} }
end 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) def fetch_match!(match_id)
response = client.get("/matches/#{match_id}") response = client.get("/matches/#{match_id}")
expect_status!(response, 200, "fetch match #{match_id}") expect_status!(response, 200, "fetch match #{match_id}")
@ -377,6 +505,12 @@ module TurniereE2E
response.fetch(:json) response.fetch(:json)
end 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) def group_stage_of!(tournament)
tournament.fetch(:stages).find { |stage| stage.fetch(:level) == -1 } || raise('group stage not found') tournament.fetch(:stages).find { |stage| stage.fetch(:level) == -1 } || raise('group stage not found')
end end
@ -431,7 +565,35 @@ module TurniereE2E
match = matches_by_teams.fetch(team_names.sort) do match = matches_by_teams.fetch(team_names.sort) do
raise "match for #{team_names.inspect} not found" raise "match for #{team_names.inspect} not found"
end 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
end end
@ -464,11 +626,40 @@ module TurniereE2E
description: 'HTTP lifecycle E2E tournament', description: 'HTTP lifecycle E2E tournament',
public: false, public: false,
group_stage: true, group_stage: true,
playoff_teams_amount:, playoff_teams_amount: playoff_teams_amount,
teams: teams: teams
} }
end 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:) def create_playoff_only_payload(team_count:)
{ {
name: "HTTP E2E Playoff Only #{team_count} #{SecureRandom.hex(3)}", name: "HTTP E2E Playoff Only #{team_count} #{SecureRandom.hex(3)}",
@ -559,6 +750,19 @@ module TurniereE2E
} }
end 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) def fetch_tournament_profile!(tournament_id)
started_at = Process.clock_gettime(Process::CLOCK_MONOTONIC) started_at = Process.clock_gettime(Process::CLOCK_MONOTONIC)
response = get_response!("/tournaments/#{tournament_id}", params: { profile: 'true' }) response = get_response!("/tournaments/#{tournament_id}", params: { profile: 'true' })
@ -574,7 +778,7 @@ module TurniereE2E
end end
def parse_server_timing(header) def parse_server_timing(header)
return {} if header.blank? return {} if header.to_s.empty?
header.split(',').each_with_object({}) do |entry, sections| header.split(',').each_with_object({}) do |entry, sections|
name, duration = entry.strip.match(/\A([^;]+);dur=([0-9.]+)\z/)&.captures name, duration = entry.strip.match(/\A([^;]+);dur=([0-9.]+)\z/)&.captures
@ -586,13 +790,17 @@ module TurniereE2E
def result(session:, tournament:, checkpoints:, stop_at:) def result(session:, tournament:, checkpoints:, stop_at:)
{ {
session:, session: session,
stop_at: stop_at.to_s, stop_at: stop_at.to_s,
tournament: summarize_tournament(tournament), tournament: summarize_tournament(tournament),
checkpoints: checkpoints: checkpoints
} }
end 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) def expect_status!(response, expected_status, action)
return if response.fetch(:status) == expected_status return if response.fetch(:status) == expected_status
@ -604,14 +812,3 @@ module TurniereE2E
end end
end 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

104
e2e/load_test.rb Executable file
View File

@ -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)}"
)

View File

@ -52,6 +52,8 @@ result = case scenario
runner.run_group_stage_main_usecase(stop_at: options[:stop_at] || :final_finish_attempted) runner.run_group_stage_main_usecase(stop_at: options[:stop_at] || :final_finish_attempted)
when 'group_stage_intermediate_round' when 'group_stage_intermediate_round'
runner.run_group_stage_intermediate_round(stop_at: options[:stop_at] || :final_finish_attempted) 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' when 'playoff_only'
runner.run_playoff_only(team_count: options[:team_count], stop_at: options[:stop_at] || :completed) runner.run_playoff_only(team_count: options[:team_count], stop_at: options[:stop_at] || :completed)
when 'group_stage_render_profile' when 'group_stage_render_profile'

View File

@ -4,5 +4,9 @@ module AuthHelpers
def apply_authentication_headers_for(user) def apply_authentication_headers_for(user)
user_headers = user.create_new_auth_token user_headers = user.create_new_auth_token
request.headers.merge!(user_headers) 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
end end

View File

@ -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

View File

@ -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

View File

@ -77,9 +77,9 @@ RSpec.describe BeamersController, type: :controller do
before(:each) { apply_authentication_headers_for @owner } before(:each) { apply_authentication_headers_for @owner }
it 'creates a new beamer' do it 'creates a new beamer' do
expect do expect {
post :create, params: { tournament_id: @tournament.to_param }.merge(valid_params) 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) expect(response).to have_http_status(:created)
end end
@ -95,14 +95,14 @@ RSpec.describe BeamersController, type: :controller do
post :create, params: { post :create, params: {
tournament_id: @tournament.to_param, name: 'bad', display_state: 'invalid' 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 end
it 'rejects duplicate name in same tournament' do it 'rejects duplicate name in same tournament' do
post :create, params: { post :create, params: {
tournament_id: @tournament.to_param, name: @beamer.name, display_state: 'during_game' 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
end end
@ -157,7 +157,7 @@ RSpec.describe BeamersController, type: :controller do
tournament_id: @tournament.to_param, id: @beamer.to_param, tournament_id: @tournament.to_param, id: @beamer.to_param,
display_state: 'bogus' display_state: 'bogus'
} }
expect(response).to have_http_status(:unprocessable_entity) expect(response).to have_http_status(:unprocessable_content)
end end
end end
@ -194,11 +194,11 @@ RSpec.describe BeamersController, type: :controller do
before(:each) { apply_authentication_headers_for @owner } before(:each) { apply_authentication_headers_for @owner }
it 'deletes the beamer' do it 'deletes the beamer' do
expect do expect {
delete :destroy, params: { delete :destroy, params: {
tournament_id: @tournament.to_param, id: @beamer.to_param 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) expect(response).to have_http_status(:no_content)
end end
end end

View File

@ -24,18 +24,17 @@ RSpec.describe GroupsController, type: :controller do
it 'returns ranking_decision metadata for group scores' do it 'returns ranking_decision metadata for group scores' do
tournament = @group.stage.tournament tournament = @group.stage.tournament
tournament.update!(playoff_teams_amount: 2, instant_finalists_amount: 2, tournament.update!(playoff_teams_amount: 2, instant_finalists_amount: 2, intermediate_round_participants_amount: 0)
intermediate_round_participants_amount: 0)
@group.stage.update!(level: -1) @group.stage.update!(level: -1)
@group.matches.destroy_all @group.matches.destroy_all
@group.group_scores.destroy_all @group.group_scores.destroy_all
team_a = create(:team, tournament:, name: 'A') team_a = create(:team, tournament: tournament, name: 'A')
team_b = create(:team, tournament:, name: 'B') team_b = create(:team, tournament: tournament, name: 'B')
create(:group_match, group: @group, state: :finished).tap do |match| create(:group_match, group: @group, state: :finished).tap do |match|
create(:match_score, match:, team: team_a, points: 3) create(:match_score, match: match, team: team_a, points: 3)
create(:match_score, match:, team: team_b, points: 1) create(:match_score, match: match, team: team_b, points: 1)
end end
score_a = create(:group_score, group: @group, team: team_a, group_points: 3, scored_points: 3, received_points: 1) score_a = create(:group_score, group: @group, team: team_a, group_points: 3, scored_points: 3, received_points: 1)

View File

@ -51,6 +51,23 @@ RSpec.describe MatchScoresController, type: :controller do
expect(body[:points]).to eq(valid_update[:points]) expect(body[:points]).to eq(valid_update[:points])
expect(body[:hidden_points]).to eq(valid_update[:hidden_points]) expect(body[:hidden_points]).to eq(valid_update[:hidden_points])
end 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 end
context 'when match_score update fails' do context 'when match_score update fails' do
@ -62,7 +79,7 @@ RSpec.describe MatchScoresController, type: :controller do
it 'returns unprocessable entity' do it 'returns unprocessable entity' do
put :update, params: { id: @match_score.to_param }.merge(valid_update) 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 end
end end

View File

@ -47,7 +47,7 @@ RSpec.describe MatchesController, type: :controller do
get :index, params: { tournament_id: @tournament.to_param } get :index, params: { tournament_id: @tournament.to_param }
match = deserialize_response(response).first 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)) expect(match[:teams]).to all(include(:id, :name))
end end
@ -72,7 +72,7 @@ RSpec.describe MatchesController, type: :controller do
expect(body.map { |match| match[:id] }).to match_array(stage.matches.map(&:id)) expect(body.map { |match| match[:id] }).to match_array(stage.matches.map(&:id))
end end
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 end
describe 'GET #show' do describe 'GET #show' do
@ -86,14 +86,12 @@ RSpec.describe MatchesController, type: :controller do
get :show, params: { id: @match.to_param } get :show, params: { id: @match.to_param }
body = deserialize_response response body = deserialize_response response
expect(body[:state]).to eq(@match.state) 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
end end
describe 'POST #create' do describe 'POST #create' do
let(:group_stage_tournament) do let(:group_stage_tournament) { create(:group_stage_tournament, group_count: 1, match_factory: :finished_group_match) }
create(:group_stage_tournament, group_count: 1, match_factory: :finished_group_match)
end
let(:group) { group_stage_tournament.stages.find_by(level: -1).groups.first } let(:group) { group_stage_tournament.stages.find_by(level: -1).groups.first }
let(:team_ids) { group.teams.first(2).map(&:id) } let(:team_ids) { group.teams.first(2).map(&:id) }
@ -103,8 +101,10 @@ RSpec.describe MatchesController, type: :controller do
end end
it 'creates a decider match at the end of the group' do it 'creates a decider match at the end of the group' do
log_count = group_stage_tournament.transaction_log_entries.count
expect do 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) end.to change { group.matches.reload.count }.by(1)
expect(response).to have_http_status(:created) 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.decider?).to eq(true)
expect(created_match.state).to eq('not_started') expect(created_match.state).to eq('not_started')
expect(created_match.teams.map(&:id)).to match_array(team_ids) 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
end end
@ -122,7 +124,7 @@ RSpec.describe MatchesController, type: :controller do
it 'rejects the request' do it 'rejects the request' do
post :create, params: { group_id: group.to_param, team_ids: [group.teams.first.id] } 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 end
end end
@ -160,8 +162,8 @@ RSpec.describe MatchesController, type: :controller do
expect(last_match.reload.position).to eq(original_first_position) expect(last_match.reload.position).to eq(original_first_position)
body = deserialize_response(response) body = deserialize_response(response)
expect(body.pluck(:id)).to match_array([first_match.id, last_match.id]) expect(body.map { |match| match[: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[:position] }).to match_array([original_first_position, original_last_position])
end end
it 'rejects matches from different groups' do 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 } 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') expect(deserialize_response(response)[:error]).to eq('Matches must be different group matches from the same group')
end end
@ -194,6 +196,102 @@ RSpec.describe MatchesController, type: :controller do
end end
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 describe 'POST #update' do
context 'on a running playoff match' do context 'on a running playoff match' do
let(:valid_update) do let(:valid_update) do
@ -235,6 +333,59 @@ RSpec.describe MatchesController, type: :controller do
expect(body[:state]).to eq(valid_update[:state]) expect(body[:state]).to eq(valid_update[:state])
end 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 context 'on a running playoff match' do
let(:finished) do let(:finished) do
{ {
@ -290,7 +441,7 @@ RSpec.describe MatchesController, type: :controller do
end end
it 'returns an unprocessable entity response' do it 'returns an unprocessable entity response' do
expect(response).to have_http_status(:unprocessable_entity) expect(response).to have_http_status(:unprocessable_content)
end end
it 'doesn\'t change the matches status' do it 'doesn\'t change the matches status' do
@ -323,7 +474,7 @@ RSpec.describe MatchesController, type: :controller do
it 'returns unprocessable entity' do it 'returns unprocessable entity' do
put :update, params: { id: @running_playoff_match.to_param }.merge(finished) 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 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) 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) 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 end
end end
@ -377,14 +528,22 @@ RSpec.describe MatchesController, type: :controller do
context 'with invalid params' do context 'with invalid params' do
it 'renders an unprocessable entity response' do it 'renders an unprocessable entity response' do
put :update, params: { id: @match.to_param }.merge(invalid_update) 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
end end
context 'with senseless params' do context 'with senseless params' do
it 'renders an unprocessable entity response' do it 'renders an unprocessable entity response' do
put :update, params: { id: @match.to_param }.merge(senseless_update) 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 end
end end
@ -403,6 +562,34 @@ RSpec.describe MatchesController, type: :controller do
end end
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 context 'on a playoff match that isn\'t ready yet' do
let(:invalid_update) do let(:invalid_update) do
{ {
@ -418,7 +605,7 @@ RSpec.describe MatchesController, type: :controller do
context 'with invalid params' do context 'with invalid params' do
it 'renders an unprocessable entity response' do it 'renders an unprocessable entity response' do
put :update, params: { id: @not_ready_playoff_match.to_param }.merge(invalid_update) 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 end
end end

View File

@ -26,7 +26,7 @@ RSpec.describe StagesController, type: :controller do
match.save! match.save!
end end
UpdateGroupsGroupScoresAndSave.call(group:) UpdateGroupsGroupScoresAndSave.call(group: group)
end end
stage.reload stage.reload
@ -97,6 +97,13 @@ RSpec.describe StagesController, type: :controller do
tournament = running_group_stage.tournament.reload tournament = running_group_stage.tournament.reload
expect(tournament.team_action_lists).to be_empty expect(tournament.team_action_lists).to be_empty
end 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 end
context 'as another user' do context 'as another user' do
@ -122,7 +129,7 @@ RSpec.describe StagesController, type: :controller do
end end
it 'it returns unprocessable entity' do it 'it returns unprocessable entity' do
expect(response).to have_http_status(:unprocessable_entity) expect(response).to have_http_status(:unprocessable_content)
end end
it 'returns the correct error' do it 'returns the correct error' do
@ -145,7 +152,7 @@ RSpec.describe StagesController, type: :controller do
end end
it 'it returns unprocessable entity' do it 'it returns unprocessable entity' do
expect(response).to have_http_status(:unprocessable_entity) expect(response).to have_http_status(:unprocessable_content)
end end
it 'returns the correct error' do it 'returns the correct error' do
@ -164,7 +171,7 @@ RSpec.describe StagesController, type: :controller do
end end
it 'it returns unprocessable entity' do it 'it returns unprocessable entity' do
expect(response).to have_http_status(:unprocessable_entity) expect(response).to have_http_status(:unprocessable_content)
end end
it 'returns the correct error' do it 'returns the correct error' do
@ -179,20 +186,17 @@ RSpec.describe StagesController, type: :controller do
before do before do
tournament = group_stage.tournament tournament = group_stage.tournament
tournament.update!(playoff_teams_amount: 1, instant_finalists_amount: 1, tournament.update!(playoff_teams_amount: 1, instant_finalists_amount: 1, intermediate_round_participants_amount: 0)
intermediate_round_participants_amount: 0)
group = create(:group, stage: group_stage, match_count: 0, number: 1) group = create(:group, stage: group_stage, match_count: 0, number: 1)
group_stage.groups = [group] group_stage.groups = [group]
team_a = create(:team, tournament:, name: 'A') team_a = create(:team, tournament: tournament, name: 'A')
team_b = create(:team, tournament:, name: 'B') team_b = create(:team, tournament: tournament, name: 'B')
create(:group_match, group:, state: :finished).tap do |match| create(:group_match, group: group, state: :finished).tap do |match|
create(:match_score, match:, team: team_a, points: 2) create(:match_score, match: match, team: team_a, points: 2)
create(:match_score, match:, team: team_b, points: 2) create(:match_score, match: match, team: team_b, points: 2)
end end
score_a = create(:group_score, group:, team: team_a, group_points: 7, scored_points: 4, score_a = create(:group_score, group: group, team: team_a, group_points: 7, scored_points: 4, received_points: 1)
received_points: 1) score_b = create(:group_score, group: group, team: team_b, 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)
GroupStageService.send(:recalculate_position_of_group_scores!, [score_a, score_b]).each(&:save!) GroupStageService.send(:recalculate_position_of_group_scores!, [score_a, score_b]).each(&:save!)
apply_authentication_headers_for group_stage.owner apply_authentication_headers_for group_stage.owner
@ -200,7 +204,7 @@ RSpec.describe StagesController, type: :controller do
end end
it 'returns unprocessable entity' do it 'returns unprocessable entity' do
expect(response).to have_http_status(:unprocessable_entity) expect(response).to have_http_status(:unprocessable_content)
end end
it 'returns a helpful decider error' do 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].size).to eq(1)
expect(body[:blocking_ties].first[:team_names]).to eq(%w[A B]) expect(body[:blocking_ties].first[:team_names]).to eq(%w[A B])
end 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 end
context 'when tied advancing teams still need a decider to fix playoff seeding' do 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 before do
tournament = group_stage.tournament tournament = group_stage.tournament
tournament.update!(playoff_teams_amount: 2, instant_finalists_amount: 2, tournament.update!(playoff_teams_amount: 2, instant_finalists_amount: 2, intermediate_round_participants_amount: 0)
intermediate_round_participants_amount: 0)
group = create(:group, stage: group_stage, match_count: 0, number: 1) group = create(:group, stage: group_stage, match_count: 0, number: 1)
group_stage.groups = [group] group_stage.groups = [group]
team_a = create(:team, tournament:, name: 'A') team_a = create(:team, tournament: tournament, name: 'A')
team_b = create(:team, tournament:, name: 'B') team_b = create(:team, tournament: tournament, name: 'B')
create(:group_match, group:, state: :finished).tap do |match| create(:group_match, group: group, state: :finished).tap do |match|
create(:match_score, match:, team: team_a, points: 2) create(:match_score, match: match, team: team_a, points: 2)
create(:match_score, match:, team: team_b, points: 2) create(:match_score, match: match, team: team_b, points: 2)
end end
score_a = create(:group_score, group:, team: team_a, group_points: 7, scored_points: 4, score_a = create(:group_score, group: group, team: team_a, group_points: 7, scored_points: 4, received_points: 1)
received_points: 1) score_b = create(:group_score, group: group, team: team_b, 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)
GroupStageService.send(:recalculate_position_of_group_scores!, [score_a, score_b]).each(&:save!) GroupStageService.send(:recalculate_position_of_group_scores!, [score_a, score_b]).each(&:save!)
apply_authentication_headers_for group_stage.owner apply_authentication_headers_for group_stage.owner
@ -239,7 +247,7 @@ RSpec.describe StagesController, type: :controller do
end end
it 'returns unprocessable entity' do it 'returns unprocessable entity' do
expect(response).to have_http_status(:unprocessable_entity) expect(response).to have_http_status(:unprocessable_content)
end end
it 'returns a helpful decider error' do it 'returns a helpful decider error' do

View File

@ -47,7 +47,7 @@ RSpec.describe TeamActionListsController, type: :controller do
it 'returns unprocessable entity for unsupported source' do it 'returns unprocessable entity for unsupported source' do
post :create, params: create_params.merge(source: 'unknown_source') 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') expect(deserialize_response(response)[:error]).to eq('Unsupported team action list source: unknown_source')
end end
@ -57,7 +57,7 @@ RSpec.describe TeamActionListsController, type: :controller do
post :create, params: create_params.merge(tournament_id: empty_tournament.to_param) 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') expect(deserialize_response(response)[:error]).to eq('Source all_tournament_teams does not currently contain any teams')
end end
@ -71,7 +71,7 @@ RSpec.describe TeamActionListsController, type: :controller do
post :create, params: create_params post :create, params: create_params
expect(response).to have_http_status(:unprocessable_entity) expect(response).to have_http_status(:unprocessable_content)
end end
end end

View File

@ -19,6 +19,17 @@ RSpec.describe TeamsController, type: :controller do
body = deserialize_response response body = deserialize_response response
expect(body[:name]).to eq(@team.name) expect(body[:name]).to eq(@team.name)
end 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 end
describe 'PUT #update' do describe 'PUT #update' do
@ -46,6 +57,49 @@ RSpec.describe TeamsController, type: :controller do
body = deserialize_response response body = deserialize_response response
expect(body[:name]).to eq(valid_update[:name]) expect(body[:name]).to eq(valid_update[:name])
end 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 end
context 'with valid params as another user' do 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) expect(response).to have_http_status(:forbidden)
end end
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
end end

View File

@ -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

View File

@ -24,7 +24,7 @@ RSpec.describe TournamentsController, type: :controller do
get :index get :index
tournaments = deserialize_response response tournaments = deserialize_response response
public_tournaments = tournaments.select { |t| t[:public] } 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
end end
@ -35,15 +35,15 @@ RSpec.describe TournamentsController, type: :controller do
it 'returns all private tournaments' do it 'returns all private tournaments' do
apply_authentication_headers_for @another_user apply_authentication_headers_for @another_user
get(:index, params:) get :index, params: params
tournaments = deserialize_response response tournaments = deserialize_response response
private_tournaments = Tournament.where(owner: @another_user, public: false).pluck(:id) private_tournaments = Tournament.where(owner: @another_user, public: false).map { |t| t[:id] }
returned_private_tournaments = tournaments.filter { |t| !t[:public] }.pluck(:id) returned_private_tournaments = tournaments.filter { |t| !t[:public] }.map { |t| t[:id] }
expect(returned_private_tournaments).to match_array(private_tournaments) expect(returned_private_tournaments).to match_array(private_tournaments)
end end
it 'returns no private tournaments for unauthenticated users' do it 'returns no private tournaments for unauthenticated users' do
get(:index, params:) get :index, params: params
tournaments = deserialize_response response tournaments = deserialize_response response
private_tournaments = tournaments.reject { |t| t[:public] } private_tournaments = tournaments.reject { |t| t[:public] }
expect(private_tournaments.size).to eq(0) 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 it 'returns no private tournaments owned by another user' do
apply_authentication_headers_for @user apply_authentication_headers_for @user
get(:index, params:) get :index, params: params
tournaments = deserialize_response response 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 end
it 'returns no public tournaments' do it 'returns no public tournaments' do
apply_authentication_headers_for @another_user apply_authentication_headers_for @another_user
get(:index, params:) get :index, params: params
tournaments = deserialize_response response tournaments = deserialize_response response
expect(tournaments.count { |t| t[:public] }).to eq(0) expect(tournaments.count { |t| t[:public] }).to eq(0)
end end
@ -70,15 +70,15 @@ RSpec.describe TournamentsController, type: :controller do
end end
it 'returns all public tournaments' do it 'returns all public tournaments' do
get(:index, params:) get :index, params: params
tournaments = deserialize_response response tournaments = deserialize_response response
public_tournaments = tournaments.select { |t| t[:public] } 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
it 'returns no private tournaments' do it 'returns no private tournaments' do
apply_authentication_headers_for @another_user apply_authentication_headers_for @another_user
get(:index, params:) get :index, params: params
tournaments = deserialize_response response tournaments = deserialize_response response
expect(tournaments.count { |t| !t[:public] }).to eq(0) expect(tournaments.count { |t| !t[:public] }).to eq(0)
end end
@ -105,7 +105,8 @@ RSpec.describe TournamentsController, type: :controller do
expected_keys = %i[ expected_keys = %i[
id name code public description playoff_teams_amount id name code public description playoff_teams_amount
instant_finalists_amount intermediate_round_participants_amount instant_finalists_amount intermediate_round_participants_amount
timestamp timer_mode owner_username stages teams team_action_lists 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.keys).to match_array(expected_keys)
expect(json[:name]).to eq(@tournament.name) expect(json[:name]).to eq(@tournament.name)
@ -150,9 +151,11 @@ RSpec.describe TournamentsController, type: :controller do
json = deserialize_response(response) json = deserialize_response(response)
expect(json[:read_only_mode]).to eq(true) 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_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_pushed_at]).to eq(pushed_at.iso8601)
expect(json[:sync_last_push_error]).to eq('push failed') 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)
expect(json).not_to have_key(:sync_auth_token_digest)
expect(json).to eq(TournamentSerializer.new(@tournament, scope: @tournament.owner).as_json) expect(json).to eq(TournamentSerializer.new(@tournament, scope: @tournament.owner).as_json)
end end
@ -224,13 +227,20 @@ RSpec.describe TournamentsController, type: :controller do
describe 'GET #timer' do describe 'GET #timer' do
it 'returns timer state in ISO 8601 format' do it 'returns timer state in ISO 8601 format' do
timestamp = Time.utc(2026, 4, 13, 18, 0, 0) timestamp = Time.utc(2026, 4, 13, 18, 0, 0)
@tournament.update!(timestamp:, timer_mode: 'countdown') @tournament.update!(
timestamp: timestamp,
timer_mode: 'countdown',
timer_reason: 'custom',
timer_reason_text: 'Opening ceremony'
)
get :timer, params: { id: @tournament.to_param } get :timer, params: { id: @tournament.to_param }
expect(response).to be_successful expect(response).to be_successful
expect(deserialize_response(response)[:timestamp]).to eq(timestamp.iso8601) expect(deserialize_response(response)[:timestamp]).to eq(timestamp.iso8601)
expect(deserialize_response(response)[:timer_mode]).to eq('countdown') expect(deserialize_response(response)[:timer_mode]).to eq('countdown')
expect(deserialize_response(response)[:timer_reason]).to eq('custom')
expect(deserialize_response(response)[:timer_reason_text]).to eq('Opening ceremony')
end end
end end
@ -242,43 +252,143 @@ RSpec.describe TournamentsController, type: :controller do
it 'stores timezone-aware timestamp values without shifting them' do it 'stores timezone-aware timestamp values without shifting them' do
timestamp = '2030-04-13T18:00:00+02:00' timestamp = '2030-04-13T18:00:00+02:00'
patch :set_timer, params: { id: @tournament.to_param, timestamp: } patch :set_timer, params: {
id: @tournament.to_param,
timestamp: timestamp,
timer_reason: 'tournament_start'
}
expect(response).to be_successful expect(response).to be_successful
expect(@tournament.reload.timestamp.iso8601).to eq(Time.iso8601(timestamp).utc.iso8601) expect(@tournament.reload.timestamp.iso8601).to eq(Time.iso8601(timestamp).utc.iso8601)
expect(@tournament.timer_reason).to eq('tournament_start')
expect(@tournament.timer_reason_text).to be_nil
expect(deserialize_response(response)[:timestamp]).to eq(@tournament.timestamp.iso8601) expect(deserialize_response(response)[:timestamp]).to eq(@tournament.timestamp.iso8601)
expect(deserialize_response(response)[:timer_mode]).to eq('countdown') expect(deserialize_response(response)[:timer_mode]).to eq('countdown')
expect(deserialize_response(response)[:timer_reason]).to eq('tournament_start')
end end
it 'starts countup timer at current time when timer_mode=countup' do it 'starts countup timer at current time when timer_mode=countup' do
before_request = Time.zone.now before_request = Time.zone.now
patch :set_timer, params: { id: @tournament.to_param, timer_mode: 'countup' } patch :set_timer, params: {
id: @tournament.to_param,
timer_mode: 'countup',
timer_reason: 'custom',
timer_reason_text: 'Tie-break delay'
}
expect(response).to be_successful expect(response).to be_successful
expect(@tournament.reload.timer_mode).to eq('countup') expect(@tournament.reload.timer_mode).to eq('countup')
expect(@tournament.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_mode]).to eq('countup')
expect(deserialize_response(response)[:timer_reason]).to eq('custom')
expect(deserialize_response(response)[:timer_reason_text]).to eq('Tie-break delay')
end end
it 'clears timer state when clear_timer=true' do it 'clears timer state when clear_timer=true' do
@tournament.update!(timestamp: Time.utc(2030, 4, 13, 18, 0, 0), timer_mode: 'countdown') @tournament.update!(
timestamp: Time.utc(2030, 4, 13, 18, 0, 0),
timer_mode: 'countdown',
timer_reason: 'custom',
timer_reason_text: 'Warm-up pause'
)
patch :set_timer, params: { id: @tournament.to_param, clear_timer: true } patch :set_timer, params: { id: @tournament.to_param, clear_timer: true }
expect(response).to be_successful expect(response).to be_successful
expect(@tournament.reload.timestamp).to be_nil expect(@tournament.reload.timestamp).to be_nil
expect(@tournament.timer_mode).to be_nil expect(@tournament.timer_mode).to be_nil
expect(@tournament.timer_reason).to be_nil
expect(@tournament.timer_reason_text).to be_nil
expect(deserialize_response(response)[:timestamp]).to be_nil expect(deserialize_response(response)[:timestamp]).to be_nil
expect(deserialize_response(response)[:timer_mode]).to be_nil expect(deserialize_response(response)[:timer_mode]).to be_nil
expect(deserialize_response(response)[:timer_reason]).to be_nil
expect(deserialize_response(response)[:timer_reason_text]).to be_nil
end
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 end
it 'rejects timestamp_seconds for countup mode' do it 'rejects timestamp_seconds for countup mode' do
patch :set_timer, params: { id: @tournament.to_param, timer_mode: 'countup', timestamp_seconds: 120 } 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') expect(deserialize_response(response)[:error]).to eq('timestamp_seconds is only allowed for countdown mode')
end end
it 'rejects invalid timer reason values' do
patch :set_timer, params: { id: @tournament.to_param, timestamp_seconds: 120, timer_reason: 'ceremony' }
expect(response).to have_http_status(:unprocessable_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 end
describe 'POST #create' do describe 'POST #create' do
@ -329,8 +439,12 @@ RSpec.describe TournamentsController, type: :controller do
expect(response).to have_http_status(:created) expect(response).to have_http_status(:created)
tournament = Tournament.find(deserialize_response(response)[:id]) tournament = Tournament.find(deserialize_response(response)[:id])
expect(tournament.read_only_mode?).to eq(true) 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.teams).to be_empty
expect(tournament.stages).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
end end
@ -341,6 +455,25 @@ RSpec.describe TournamentsController, type: :controller do
end.to change(Tournament, :count).by(1) end.to change(Tournament, :count).by(1)
end 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 it 'associates the new tournament with the authenticated user' do
expect do expect do
post :create, params: create_playoff_tournament_data post :create, params: create_playoff_tournament_data
@ -406,7 +539,7 @@ RSpec.describe TournamentsController, type: :controller do
context 'playoff_teams_amount unacceptable' do context 'playoff_teams_amount unacceptable' do
shared_examples_for 'wrong playoff_teams_amount' do shared_examples_for 'wrong playoff_teams_amount' do
it 'fails' do it 'fails' do
expect(response).to have_http_status(:unprocessable_entity) expect(response).to have_http_status(:unprocessable_content)
end end
it 'returns the correct error message' do it 'returns the correct error message' do
expect(deserialize_response(response)[:playoff_teams_amount].first) expect(deserialize_response(response)[:playoff_teams_amount].first)
@ -462,7 +595,7 @@ RSpec.describe TournamentsController, type: :controller do
data = create_group_tournament_data data = create_group_tournament_data
data[:teams].pop data[:teams].pop
post :create, params: data post :create, params: data
expect(response).to have_http_status(:unprocessable_entity) expect(response).to have_http_status(:unprocessable_content)
end end
end end
@ -475,12 +608,35 @@ RSpec.describe TournamentsController, type: :controller do
post :create, params: data post :create, params: data
end.to change(Team, :count).by(data[:teams].count) end.to change(Team, :count).by(data[:teams].count)
end 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 end
context 'with invalid parameters' do context 'with invalid parameters' do
it 'renders an unprocessable entity response' do it 'renders an unprocessable entity response' do
put :create, params: { teams: [1, 2, 3] } 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
end end
@ -489,7 +645,7 @@ RSpec.describe TournamentsController, type: :controller do
data = create_group_tournament_data data = create_group_tournament_data
data[:teams] = [{ group: 1 }, { group: 1 }, { group: 2 }, { group: 2 }] data[:teams] = [{ group: 1 }, { group: 1 }, { group: 2 }, { group: 2 }]
post :create, params: data post :create, params: data
expect(response).to have_http_status(:unprocessable_entity) expect(response).to have_http_status(:unprocessable_content)
end end
end end
end end
@ -521,6 +677,43 @@ RSpec.describe TournamentsController, type: :controller do
expect(@tournament.name).to eq(valid_update[:name]) expect(@tournament.name).to eq(valid_update[:name])
end 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 it 'renders a JSON response with the tournament' do
put :update, params: { id: @tournament.to_param }.merge(valid_update) put :update, params: { id: @tournament.to_param }.merge(valid_update)
expect(response).to have_http_status(:ok) 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.read_only_mode?).to eq(false)
expect(@tournament.sync_target_url).to include('/sync_state') expect(@tournament.sync_target_url).to include('/sync_state')
expect(@tournament.sync_auth_token).to eq('new-token') 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 end
it 'allows follower takeover when only read_only_mode is disabled' do 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.read_only_mode?).to eq(false)
expect(@tournament.sync_target_url).to be_blank expect(@tournament.sync_target_url).to be_blank
expect(@tournament.sync_auth_token).to be_blank expect(@tournament.sync_auth_token).to be_blank
expect(@tournament.sync_auth_token_digest).to be_blank
end end
it 'blocks normal updates while tournament is read only' do 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 it 'fails when only instant_finalists_amount is changed' do
put :update, params: { id: @filled_tournament.to_param }.merge(instant_finalists_amount: 29) 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 end
it 'fails when only intermediate_round_participants_amount is changed' do 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) 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 end
it 'fails when parameters don\'t match' do it 'fails when parameters don\'t match' do
put :update, params: { id: @filled_tournament.to_param }.merge(intermediate_round_participants_amount: 29, put :update, params: { id: @filled_tournament.to_param }.merge(intermediate_round_participants_amount: 29,
instant_finalists_amount: 32) instant_finalists_amount: 32)
expect(response).to have_http_status(:unprocessable_entity) expect(response).to have_http_status(:unprocessable_content)
end end
it 'succeeds when all three are changed correctly' do it 'succeeds when all three are changed correctly' do
@ -607,7 +804,7 @@ RSpec.describe TournamentsController, type: :controller do
it 'returns unprocessable entity' do it 'returns unprocessable entity' do
put :update, params: { id: @filled_tournament.to_param }.merge(playoff_teams_amount: 8) 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 end
it 'doesn\'t change playoff_teams_amount' do 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 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) 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 end
end end
@ -724,6 +921,119 @@ RSpec.describe TournamentsController, type: :controller do
end end
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 describe 'PATCH #sync_state' do
before do before do
@tournament.update!(read_only_mode: true, sync_auth_token: 'shared-secret') @tournament.update!(read_only_mode: true, sync_auth_token: 'shared-secret')
@ -740,6 +1050,10 @@ RSpec.describe TournamentsController, type: :controller do
code: 'abc123', code: 'abc123',
description: 'Synced description', description: 'Synced description',
public: true, 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, playoff_teams_amount: 4,
instant_finalists_amount: 4, instant_finalists_amount: 4,
intermediate_round_participants_amount: 0, intermediate_round_participants_amount: 0,
@ -757,7 +1071,13 @@ RSpec.describe TournamentsController, type: :controller do
@tournament.reload @tournament.reload
expect(@tournament.sync_source_tournament_id).to eq(123) expect(@tournament.sync_source_tournament_id).to eq(123)
expect(@tournament.name).to eq('Synced Tournament') 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(@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 end
it 'rejects invalid tokens' do it 'rejects invalid tokens' do
@ -797,13 +1117,16 @@ RSpec.describe TournamentsController, type: :controller do
expect(json[:id]).to eq(@tournament.id) expect(json[:id]).to eq(@tournament.id)
expect(json[:sync_last_pushed_at]).to eq(pushed_at.iso8601) expect(json[:sync_last_pushed_at]).to eq(pushed_at.iso8601)
expect(json[:sync_last_push_error]).to be_nil 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) expect(TournamentSyncPusher).to have_received(:push!).with(@tournament)
end end
it 'rejects sync test without complete leader configuration' do it 'rejects sync test without complete leader configuration' do
post :test_sync, params: { id: @tournament.to_param } 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]) expect(deserialize_response(response)[:error])
.to eq('Tournament sync test requires writable tournament with sync_target_url and sync_auth_token') .to eq('Tournament sync test requires writable tournament with sync_target_url and sync_auth_token')
end end
@ -828,4 +1151,54 @@ RSpec.describe TournamentsController, type: :controller do
expect(json.dig(:tournament, :sync_last_push_error)).to eq('connection refused') expect(json.dig(:tournament, :sync_last_push_error)).to eq('connection refused')
end end
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 end

View File

@ -20,7 +20,7 @@ RSpec.describe 'Backend API surface HTTP E2E' do
let(:owner_client) { login_client(email: owner_email, password: owner_password) } let(:owner_client) { login_client(email: owner_email, password: owner_password) }
let(:other_client) { secondary_client } 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 it 'reports health' do
response = anonymous_client.get('/healthz') response = anonymous_client.get('/healthz')
@ -33,7 +33,9 @@ RSpec.describe 'Backend API surface HTTP E2E' do
response = anonymous_client.get('/version') response = anonymous_client.get('/version')
expect(response[:status]).to eq(200) 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 end
it 'supports registration for new users and still requires confirmation for login' do 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" email = "http-e2e-#{unique}@example.com"
password = 'password123' password = 'password123'
username = "http-e2e-#{unique}" username = "http-e2e-#{unique}"
client = TurniereE2E::ApiClient.new(base_url:) client = TurniereE2E::ApiClient.new(base_url: base_url)
registration = client.post('/users', body: { registration = client.post('/users', body: {
email:, email: email,
password:, password: password,
password_confirmation: 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(registration[:json]).not_to eq({})
expect(login[:status]).to eq(401) expect(login[:status]).to eq(401)
end end
@ -66,12 +68,12 @@ RSpec.describe 'Backend API surface HTTP E2E' do
invalid_index = anonymous_client.get('/tournaments', params: { type: 'bogus' }) invalid_index = anonymous_client.get('/tournaments', params: { type: 'bogus' })
expect(public_index[:status]).to eq(200) expect(public_index[:status]).to eq(200)
expect(public_index[:json].pluck(:id)).to include(public_tournament.fetch(:id)) expect(public_index[:json].map { |tournament| tournament[: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] }).not_to include(private_tournament.fetch(:id))
expect(private_index[:status]).to eq(200) expect(private_index[:status]).to eq(200)
expect(private_index[:json].pluck(:id)).to include(private_tournament.fetch(:id)) expect(private_index[:json].map { |tournament| tournament[: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] }).not_to include(public_tournament.fetch(:id))
expect(simple_show[:status]).to eq(200) expect(simple_show[:status]).to eq(200)
expect(simple_show[:json][:id]).to eq(public_tournament.fetch(:id)) 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[:status]).to eq(200)
expect(full_show.dig(:json, :stages)).to be_an(Array) 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, :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[:status]).to eq(400)
expect(invalid_index.dig(:json, :error)).to eq('invalid type') 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' }] 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(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(playoff_only.fetch(:teams).count).to eq(4)
expect(invalid_teams[:status]).to eq(422) 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) expect(unauthenticated[:status]).to eq(401)
end 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 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') tournament = create_group_stage_tournament(client: owner_client, public: false, name_prefix: 'Mutable Tournament')
update_response = owner_client.patch("/tournaments/#{tournament.fetch(:id)}", body: { update_response = owner_client.patch("/tournaments/#{tournament.fetch(:id)}", body: {
name: "Renamed #{unique_suffix}", 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, playoff_teams_amount: 8,
instant_finalists_amount: 8, instant_finalists_amount: 8,
intermediate_round_participants_amount: 0 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' }) forbidden_update = other_client.patch("/tournaments/#{tournament.fetch(:id)}", body: { name: 'Not Allowed' })
unauthenticated_update = anonymous_client.patch("/tournaments/#{tournament.fetch(:id)}", body: { name: 'No Auth' }) unauthenticated_update = anonymous_client.patch("/tournaments/#{tournament.fetch(:id)}", body: { name: 'No Auth' })
timer_seconds = owner_client.patch("/tournaments/#{tournament.fetch(:id)}/timer", body: { timestamp_seconds: 120 }) timer_seconds = owner_client.patch("/tournaments/#{tournament.fetch(:id)}/timer", body: {
timestamp_seconds: 120,
timer_reason: 'tournament_start'
})
timer_show = anonymous_client.get("/tournaments/#{tournament.fetch(:id)}/timer") timer_show = anonymous_client.get("/tournaments/#{tournament.fetch(:id)}/timer")
timer_countup = owner_client.patch("/tournaments/#{tournament.fetch(:id)}/timer", body: { timer_mode: 'countup' }) timer_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_clear = owner_client.patch("/tournaments/#{tournament.fetch(:id)}/timer", body: { clear_timer: true })
timer_double = owner_client.patch("/tournaments/#{tournament.fetch(:id)}/timer", body: { timer_double = owner_client.patch("/tournaments/#{tournament.fetch(:id)}/timer", body: {
timestamp_seconds: 120, timestamp_seconds: 120,
timestamp: (Time.now.utc + 240).iso8601 timestamp: (Time.now.utc + 240).iso8601
}) })
timer_invalid_seconds = owner_client.patch("/tournaments/#{tournament.fetch(:id)}/timer", timer_invalid_reason = owner_client.patch("/tournaments/#{tournament.fetch(:id)}/timer", body: {
body: { timestamp_seconds: 'nope' }) timestamp_seconds: 120,
timer_reason: 'ceremony'
})
timer_custom_without_text = owner_client.patch("/tournaments/#{tournament.fetch(:id)}/timer", body: {
timestamp_seconds: 120,
timer_reason: 'custom'
})
timer_invalid_seconds = owner_client.patch("/tournaments/#{tournament.fetch(:id)}/timer", body: {
timestamp_seconds: 'nope'
})
timer_past = owner_client.patch("/tournaments/#{tournament.fetch(:id)}/timer", body: { timestamp_seconds: 0 }) timer_past = owner_client.patch("/tournaments/#{tournament.fetch(:id)}/timer", body: { timestamp_seconds: 0 })
timer_countup_seconds = owner_client.patch("/tournaments/#{tournament.fetch(:id)}/timer", body: { timer_countup_seconds = owner_client.patch("/tournaments/#{tournament.fetch(:id)}/timer", body: {
timer_mode: 'countup', timer_mode: 'countup',
@ -154,6 +251,10 @@ RSpec.describe 'Backend API surface HTTP E2E' do
expect(update_response[:status]).to eq(200) expect(update_response[:status]).to eq(200)
expect(update_response.dig(:json, :name)).to start_with('Renamed') 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[:status]).to eq(200)
expect(auto_recalc_response.dig(:json, :playoff_teams_amount)).to eq(4) 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[:status]).to eq(200)
expect(timer_seconds.dig(:json, :timestamp)).to be_a(String) expect(timer_seconds.dig(:json, :timestamp)).to be_a(String)
expect(timer_seconds.dig(:json, :timer_mode)).to eq('countdown') expect(timer_seconds.dig(:json, :timer_mode)).to eq('countdown')
expect(timer_seconds.dig(:json, :timer_reason)).to eq('tournament_start')
expect(timer_seconds.dig(:json, :timer_reason_text)).to be_nil
expect(timer_show[:status]).to eq(200) expect(timer_show[:status]).to eq(200)
expect(timer_show.dig(:json, :timestamp)).to eq(timer_seconds.dig(:json, :timestamp)) expect(timer_show.dig(:json, :timestamp)).to eq(timer_seconds.dig(:json, :timestamp))
expect(timer_show.dig(:json, :timer_mode)).to eq('countdown') expect(timer_show.dig(:json, :timer_mode)).to eq('countdown')
expect(timer_show.dig(:json, :timer_reason)).to eq('tournament_start')
expect(timer_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[:status]).to eq(200)
expect(timer_countup.dig(:json, :timestamp)).to be_a(String) expect(timer_countup.dig(:json, :timestamp)).to be_a(String)
expect(timer_countup.dig(:json, :timer_mode)).to eq('countup') expect(timer_countup.dig(:json, :timer_mode)).to eq('countup')
expect(timer_countup.dig(:json, :timer_reason)).to eq('custom')
expect(timer_countup.dig(:json, :timer_reason_text)).to eq('Pause ran long')
expect(timer_clear[:status]).to eq(200) expect(timer_clear[:status]).to eq(200)
expect(timer_clear.dig(:json, :timestamp)).to be_nil expect(timer_clear.dig(:json, :timestamp)).to be_nil
expect(timer_clear.dig(:json, :timer_mode)).to be_nil expect(timer_clear.dig(:json, :timer_mode)).to be_nil
expect(timer_clear.dig(:json, :timer_reason)).to be_nil
expect(timer_clear.dig(:json, :timer_reason_text)).to be_nil
expect(timer_double[:status]).to eq(422) expect(timer_double[:status]).to eq(422)
expect(timer_double.dig(:json, :error)).to eq('Only one of timestamp or timestamp_seconds is allowed') expect(timer_double.dig(:json, :error)).to eq('Only one of timestamp or timestamp_seconds is allowed')
expect(timer_invalid_reason[:status]).to eq(422)
expect(timer_invalid_reason.dig(:json, :error)).to eq('Invalid timer_reason')
expect(timer_custom_without_text[:status]).to eq(422)
expect(timer_custom_without_text.dig(:json, :error)).to eq(
'timer_reason_text is required for custom timer_reason'
)
expect(timer_invalid_seconds[:status]).to eq(422) expect(timer_invalid_seconds[:status]).to eq(422)
expect(timer_invalid_seconds.dig(:json, :error)).to eq('Invalid seconds format') expect(timer_invalid_seconds.dig(:json, :error)).to eq('Invalid seconds format')
expect(timer_past[:status]).to eq(422) expect(timer_past[:status]).to eq(422)
@ -209,10 +333,8 @@ RSpec.describe 'Backend API surface HTTP E2E' do
statistics_before = anonymous_client.get("/tournaments/#{tournament.fetch(:id)}/statistics") statistics_before = anonymous_client.get("/tournaments/#{tournament.fetch(:id)}/statistics")
matches_all = anonymous_client.get("/tournaments/#{tournament.fetch(:id)}/matches") matches_all = anonymous_client.get("/tournaments/#{tournament.fetch(:id)}/matches")
matches_not_started = anonymous_client.get("/tournaments/#{tournament.fetch(:id)}/matches", matches_not_started = anonymous_client.get("/tournaments/#{tournament.fetch(:id)}/matches", params: { state: 'not_started' })
params: { state: 'not_started' }) matches_upcoming = anonymous_client.get("/tournaments/#{tournament.fetch(:id)}/matches", params: { state: 'upcoming' })
matches_upcoming = anonymous_client.get("/tournaments/#{tournament.fetch(:id)}/matches",
params: { state: 'upcoming' })
matches_group_position = anonymous_client.get( matches_group_position = anonymous_client.get(
"/tournaments/#{tournament.fetch(:id)}/matches", "/tournaments/#{tournament.fetch(:id)}/matches",
params: { group_stage_position: match.fetch(:position) } 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)}") match_show = anonymous_client.get("/matches/#{match.fetch(:id)}")
invalid_match_update = owner_client.patch("/matches/#{match.fetch(:id)}", body: { state: 'bogus' }) 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' }) forbidden_match_update = other_client.patch("/matches/#{match.fetch(:id)}", body: { state: 'in_progress' })
unauthenticated_match_update = anonymous_client.patch("/matches/#{match.fetch(:id)}", unauthenticated_match_update = anonymous_client.patch("/matches/#{match.fetch(:id)}", body: { state: 'in_progress' })
body: { state: 'in_progress' })
stage_not_over = owner_client.patch("/stages/#{group_stage.fetch(:id)}", body: { state: 'finished' }) 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' }) 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' }) 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)}") started_match = anonymous_client.get("/matches/#{match.fetch(:id)}")
score_to_update = started_match.fetch(:json).fetch(:match_scores).first score_to_update = started_match.fetch(:json).fetch(:match_scores).first
score_show = anonymous_client.get("/match_scores/#{score_to_update.fetch(:id)}") 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 }) 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)}", unauthenticated_score_update = anonymous_client.patch("/match_scores/#{score_to_update.fetch(:id)}", body: { points: 3 })
body: { points: 3 })
bet_while_running = owner_client.post("/matches/#{match.fetch(:id)}/bets", body: { team: team.fetch(:id) }) 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' }) 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") playoff_stats = anonymous_client.get("/tournaments/#{playoff_only.fetch(:id)}/statistics")
tied_playoff_match = playoff_only.fetch(:stages) tied_playoff_match = playoff_only.fetch(:stages)
.flat_map { |stage| stage.fetch(:matches) } .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[:status]).to eq(200)
expect(matches_all[:json].count).to eq(tournament_match_count(tournament)) expect(matches_all[:json].count).to eq(tournament_match_count(tournament))
expect(matches_not_started[:status]).to eq(200) 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[: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[:status]).to eq(200)
expect(matches_group_position[:json].map { |item| item.dig(:stage, :level) }.uniq).to eq([-1]) 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))) 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_show[:status]).to eq(200)
expect(score_update[: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, :points)).to eq(7)
expect(score_update.dig(:json, :hidden_points)).to eq(11)
expect(forbidden_score_update[:status]).to eq(403) expect(forbidden_score_update[:status]).to eq(403)
expect(unauthenticated_score_update[:status]).to eq(401) expect(unauthenticated_score_update[:status]).to eq(401)
expect(bet_while_running[:status]).to eq(422) 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(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[:status]).to eq(422)
expect(restart_started_match.dig(:json, :error)).to eq("Match can't start in this state") 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[:status]).to eq(501)
expect(playoff_stats[:json]).to eq({}) 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) } group = group_stage.fetch(:groups).min_by { |item| item.fetch(:number) }
ordered_matches = group.fetch(:matches).sort_by { |item| item.fetch(:position) } ordered_matches = group.fetch(:matches).sort_by { |item| item.fetch(:position) }
first_match = ordered_matches.fetch(0) first_match = ordered_matches.fetch(0)
second_match = ordered_matches.fetch(1)
fourth_match = ordered_matches.fetch(3) fourth_match = ordered_matches.fetch(3)
upcoming_before = owner_client.get("/tournaments/#{tournament.fetch(:id)}/matches", params: { state: 'upcoming' }) 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) .fetch(:groups)
.find { |item| item.fetch(:id) == group.fetch(:id) } .find { |item| item.fetch(:id) == group.fetch(:id) }
swapped_matches = swapped_group.fetch(:matches).sort_by { |item| item.fetch(:position) } swapped_matches = swapped_group.fetch(:matches).sort_by { |item| item.fetch(:position) }
upcoming_after_swap = owner_client.get("/tournaments/#{tournament.fetch(:id)}/matches", upcoming_after_swap = owner_client.get("/tournaments/#{tournament.fetch(:id)}/matches", params: { state: 'upcoming' })
params: { state: 'upcoming' })
finish_group_match_with_winner!(match_id: fourth_match.fetch(:id)) finish_group_match_with_winner!(match_id: fourth_match.fetch(:id))
upcoming_after_finish = owner_client.get("/tournaments/#{tournament.fetch(:id)}/matches", upcoming_after_finish = owner_client.get("/tournaments/#{tournament.fetch(:id)}/matches", params: { state: 'upcoming' })
params: { state: 'upcoming' })
expect(upcoming_before[:status]).to eq(200) expect(upcoming_before[:status]).to eq(200)
expect(upcoming_match_for_group(upcoming_before, expect(upcoming_match_for_group(upcoming_before, group_id: group.fetch(:id)).fetch(:id)).to eq(first_match.fetch(:id))
group_id: group.fetch(:id)).fetch(:id)).to eq(first_match.fetch(:id))
expect(swap_response[:status]).to eq(200) 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(:id)).to eq(fourth_match.fetch(:id))
expect(swapped_matches.first.fetch(:position)).to eq(first_match.fetch(:position)) expect(swapped_matches.first.fetch(:position)).to eq(first_match.fetch(:position))
expect(swapped_matches.find do |match| expect(swapped_matches.find { |match| match.fetch(:id) == first_match.fetch(:id) }.fetch(:position)).to eq(fourth_match.fetch(:position))
match.fetch(:id) == first_match.fetch(:id)
end.fetch(:position)).to eq(fourth_match.fetch(:position))
expect(upcoming_after_swap[:status]).to eq(200) expect(upcoming_after_swap[:status]).to eq(200)
expect(upcoming_match_for_group(upcoming_after_swap, expect(upcoming_match_for_group(upcoming_after_swap, group_id: group.fetch(:id)).fetch(:id)).to eq(fourth_match.fetch(:id))
group_id: group.fetch(:id)).fetch(:id)).to eq(fourth_match.fetch(:id))
expect(upcoming_after_finish[:status]).to eq(200) expect(upcoming_after_finish[:status]).to eq(200)
expect(upcoming_match_for_group(upcoming_after_finish, expect(upcoming_match_for_group(upcoming_after_finish, group_id: group.fetch(:id))).to be_nil
group_id: group.fetch(:id)).fetch(:id)).to eq(second_match.fetch(:id)) 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 end
def login_client(email:, password:) def login_client(email:, password:)
client = TurniereE2E::ApiClient.new(base_url:) client = TurniereE2E::ApiClient.new(base_url: base_url)
response = client.login!(email:, password:) response = client.login!(email: email, password: password)
expect(response[:status]).to eq(200) expect(response[:status]).to eq(200)
expect(client.authenticated?).to eq(true) expect(client.authenticated?).to eq(true)
@ -401,15 +577,15 @@ RSpec.describe 'Backend API surface HTTP E2E' do
end end
def secondary_client def secondary_client
alt_email = ENV.fetch('TURNIERE_E2E_ALT_EMAIL', nil) alt_email = ENV['TURNIERE_E2E_ALT_EMAIL']
alt_password = ENV.fetch('TURNIERE_E2E_ALT_PASSWORD', nil) alt_password = ENV['TURNIERE_E2E_ALT_PASSWORD']
if alt_email && 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) response = client.login!(email: alt_email, password: alt_password)
return client if response[:status] == 200 && client.authenticated? return client if response[:status] == 200 && client.authenticated?
end end
client = TurniereE2E::ApiClient.new(base_url:) client = TurniereE2E::ApiClient.new(base_url: base_url)
unique = unique_suffix unique = unique_suffix
response = client.post('/users', body: { response = client.post('/users', body: {
email: "http-e2e-secondary-#{unique}@example.com", email: "http-e2e-secondary-#{unique}@example.com",
@ -427,11 +603,15 @@ RSpec.describe 'Backend API surface HTTP E2E' do
payload = { payload = {
name: "#{name_prefix} #{unique_suffix}", name: "#{name_prefix} #{unique_suffix}",
description: 'HTTP API surface E2E group-stage tournament', 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, group_stage: true,
playoff_teams_amount: 8, playoff_teams_amount: 8,
teams: 4.times.flat_map do |group_index| 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}", name: "#{name_prefix.tr(' ', '')}-G#{group_index + 1}-S#{seed_index + 1}",
group: group_index group: group_index
@ -442,22 +622,66 @@ RSpec.describe 'Backend API surface HTTP E2E' do
response = client.post('/tournaments', body: payload) response = client.post('/tournaments', body: payload)
expect(response[:status]).to eq(201) 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 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 = { payload = {
name: "#{name_prefix} #{unique_suffix}", name: "#{name_prefix} #{unique_suffix}",
description: 'HTTP API surface E2E playoff tournament', description: 'HTTP API surface E2E playoff tournament',
public:, public: public,
teams: Array.new(4) do |index| 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}" } { name: "#{name_prefix.tr(' ', '')}-S#{index + 1}" }
end end
} }
response = client.post('/tournaments', body: payload) response = client.post('/tournaments', body: payload)
expect(response[:status]).to eq(201) 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 end
def fetch_tournament(client:, tournament_id:) 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) expect(finish_response[:status]).to eq(200)
end 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) def tournament_match_count(tournament)
tournament.fetch(:stages).sum do |stage| tournament.fetch(:stages).sum do |stage|
stage.fetch(:matches, []).count + stage.fetch(:groups, []).sum { |group| group.fetch(:matches).count } stage.fetch(:matches, []).count + stage.fetch(:groups, []).sum { |group| group.fetch(:matches).count }

View File

@ -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

View File

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

View File

@ -86,7 +86,7 @@ RSpec.describe 'Tournament beamer websocket HTTP E2E' do
name: "#{name_prefix} #{SecureRandom.hex(3)}", name: "#{name_prefix} #{SecureRandom.hex(3)}",
description: 'Websocket beamer tournament', description: 'Websocket beamer tournament',
public: true, public: true,
teams: Array.new(4) do |index| teams: 4.times.map do |index|
{ name: "#{name_prefix.tr(' ', '')}-S#{index + 1}" } { name: "#{name_prefix.tr(' ', '')}-S#{index + 1}" }
end end
}) })
@ -95,13 +95,13 @@ RSpec.describe 'Tournament beamer websocket HTTP E2E' do
end end
def wait_for_beamer_payload!(cable_client, event_type:, display_state:, timeout: 20) def wait_for_beamer_payload!(cable_client, event_type:, display_state:, timeout: 20)
deadline = Time.zone.now + timeout deadline = Time.now + timeout
loop do 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 && return payload if payload.fetch('type') == event_type &&
payload.dig('beamer', 'display_state') == display_state 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 end
end end

View File

@ -85,7 +85,7 @@ RSpec.describe 'Tournament follower sync beamer websocket HTTP E2E' do
name: "#{name_prefix} #{SecureRandom.hex(3)}", name: "#{name_prefix} #{SecureRandom.hex(3)}",
description: 'Follower beamer websocket source tournament', description: 'Follower beamer websocket source tournament',
public: true, public: true,
teams: Array.new(4) do |index| teams: 4.times.map do |index|
{ name: "#{name_prefix.tr(' ', '')}-S#{index + 1}" } { name: "#{name_prefix.tr(' ', '')}-S#{index + 1}" }
end end
}) })
@ -125,25 +125,25 @@ RSpec.describe 'Tournament follower sync beamer websocket HTTP E2E' do
end end
def wait_for_default_beamer!(tournament_id:, timeout: 20) def wait_for_default_beamer!(tournament_id:, timeout: 20)
deadline = Time.zone.now + timeout deadline = Time.now + timeout
loop do loop do
beamer = fetch_beamer(base_url: follower_base_url, tournament_id:, beamer_name: DEFAULT_BEAMER_NAME) beamer = fetch_beamer(base_url: follower_base_url, tournament_id:, beamer_name: DEFAULT_BEAMER_NAME)
return beamer if beamer.fetch(: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 sleep 0.5
end end
end end
def wait_for_beamer_payload!(cable_client, event_type:, display_state:, timeout: 20) def wait_for_beamer_payload!(cable_client, event_type:, display_state:, timeout: 20)
deadline = Time.zone.now + timeout deadline = Time.now + timeout
loop do 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 && return payload if payload.fetch('type') == event_type &&
payload.dig('beamer', 'display_state') == display_state 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 end
end end

View File

@ -23,9 +23,7 @@ RSpec.describe 'Tournament follower sync HTTP E2E' do
let(:owner_password) { ENV.fetch('TURNIERE_E2E_PASSWORD') } let(:owner_password) { ENV.fetch('TURNIERE_E2E_PASSWORD') }
let(:sync_token) { "sync-#{SecureRandom.hex(8)}" } 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(:source_owner_client) { login_client(base_url: source_base_url, email: owner_email, password: owner_password) }
let(:follower_owner_client) do let(:follower_owner_client) { login_client(base_url: follower_base_url, email: owner_email, password: owner_password) }
login_client(base_url: follower_base_url, email: owner_email, password: owner_password)
end
let(:source_anonymous_client) { TurniereE2E::ApiClient.new(base_url: source_base_url) } let(:source_anonymous_client) { TurniereE2E::ApiClient.new(base_url: source_base_url) }
let(:follower_anonymous_client) { TurniereE2E::ApiClient.new(base_url: follower_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 sync_auth_token: sync_token
}) })
expect(configure_sync[:status]).to eq(200) 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)) source = fetch_tournament(client: source_anonymous_client, tournament_id: source.fetch(:id))
follower = wait_for_tournament_sync!(source_tournament_id: source.fetch(:id), follower = wait_for_tournament_sync!(source_tournament_id: source.fetch(:id), follower_tournament_id: follower.fetch(:id))
follower_tournament_id: follower.fetch(:id))
expect(tournament_signature(follower)).to eq(tournament_signature(source)) 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') log_phase!('swap matches on leader and verify follower behavior')
source_group_stage = source.fetch(:stages).find { |stage| stage.fetch(:level) == -1 } 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] 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_group_matches = first_source_group.fetch(:matches).sort_by { |match| match.fetch(:position) }
first_position_match = first_group_matches.fetch(0) first_position_match = first_group_matches.fetch(0)
fourth_position_match = first_group_matches.fetch(3) 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 } 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| follower_first_position_match = first_follower_group.fetch(:matches).find do |match|
match_team_names(match) == match_team_names(first_position_match) match_team_names(match) == match_team_names(first_position_match)
end end
@ -79,18 +121,15 @@ RSpec.describe 'Tournament follower sync HTTP E2E' do
fourth_position_match.fetch(:position) fourth_position_match.fetch(:position)
]) ])
source = fetch_tournament(client: source_anonymous_client, tournament_id: source.fetch(:id)) source = fetch_tournament(client: source_anonymous_client, tournament_id: source.fetch(:id))
follower = wait_for_tournament_sync!(source_tournament_id: source.fetch(:id), follower = wait_for_tournament_sync!(source_tournament_id: source.fetch(:id), follower_tournament_id: follower.fetch(:id))
follower_tournament_id: follower.fetch(:id))
expect(tournament_signature(follower)).to eq(tournament_signature(source)) expect(tournament_signature(follower)).to eq(tournament_signature(source))
expect(group_match_positions(source, group_number: first_source_group.fetch(:number))).to eq( 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)) group_match_positions(follower, group_number: first_follower_group.fetch(:number))
) )
expect(group_match_positions(source, expect(group_match_positions(source, group_number: first_source_group.fetch(:number)).fetch(match_team_names(first_position_match))).to eq(
group_number: first_source_group.fetch(:number)).fetch(match_team_names(first_position_match))).to eq(
fourth_position_match.fetch(:position) fourth_position_match.fetch(:position)
) )
expect(group_match_positions(source, expect(group_match_positions(source, group_number: first_source_group.fetch(:number)).fetch(match_team_names(fourth_position_match))).to eq(
group_number: first_source_group.fetch(:number)).fetch(match_team_names(fourth_position_match))).to eq(
first_position_match.fetch(:position) 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[:status]).to eq(422)
expect(wrong_group_swap.dig(:json, :error)).to eq('Matches must be different group matches from the same group') 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)) 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, follower_after_invalid_swap = fetch_tournament(client: follower_anonymous_client, tournament_id: follower.fetch(:id))
tournament_id: follower.fetch(:id))
expect(tournament_signature(source_after_invalid_swap)).to eq(source_before_invalid_swap) 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) 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') log_phase!('tighten playoff cutoff and rename teams')
update_cutoff = source_owner_client.patch("/tournaments/#{source.fetch(:id)}", body: { 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, playoff_teams_amount: 4,
instant_finalists_amount: 4, instant_finalists_amount: 4,
intermediate_round_participants_amount: 0 intermediate_round_participants_amount: 0
}) })
expect(update_cutoff[:status]).to eq(200) 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)) source_group_stage = fetch_tournament(client: source_anonymous_client, tournament_id: source.fetch(:id))
.fetch(:stages).find { |stage| stage.fetch(:level) == -1 } .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) 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', source_noise.concat(seed_unrelated_tournaments!(client: source_owner_client, prefix: 'Source Midway Noise', range: 2..3))
range: 2..3)) follower_noise.concat(seed_unrelated_tournaments!(client: follower_owner_client, prefix: 'Follower Midway Noise', range: 3..4))
follower_noise.concat(seed_unrelated_tournaments!(client: follower_owner_client, prefix: 'Follower Midway Noise', exercise_unrelated_requests!(source_noise: source_noise, follower_noise: follower_noise)
range: 3..4))
exercise_unrelated_requests!(source_noise:, follower_noise:)
log_phase!('play group stage with deciders') 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| play_group_with_decider_lifecycle!(source_id: source.fetch(:id), groups: source_group_stage.fetch(:groups).sort_by { |group| group.fetch(:number) })
group.fetch(:number) exercise_unrelated_requests!(source_noise: source_noise, follower_noise: follower_noise)
end)
exercise_unrelated_requests!(source_noise:, follower_noise:)
log_phase!('verify playoff generation replicated') log_phase!('verify playoff generation replicated')
source = fetch_tournament(client: source_anonymous_client, tournament_id: source.fetch(:id)) source = fetch_tournament(client: source_anonymous_client, tournament_id: source.fetch(:id))
follower = wait_for_tournament_sync!(source_tournament_id: source.fetch(:id), follower = wait_for_tournament_sync!(source_tournament_id: source.fetch(:id), follower_tournament_id: follower.fetch(:id))
follower_tournament_id: follower.fetch(:id))
expect(tournament_signature(follower)).to eq(tournament_signature(source)) expect(tournament_signature(follower)).to eq(tournament_signature(source))
expect(follower.fetch(:stages).map { |stage| stage.fetch(:level) }).to include(-1, 0, 1) 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(source.fetch(:team_action_lists)).to eq([])
expect(follower.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') log_phase!('create team action list on source and verify follower mirrors it')
created_list = create_team_action_list!(source.fetch(:id)) created_list = create_team_action_list!(source.fetch(:id))
expect(created_list.fetch(:key)).to eq(playoff_token_list_key) expect(created_list.fetch(:key)).to eq(playoff_token_list_key)
source = fetch_tournament(client: source_anonymous_client, tournament_id: source.fetch(:id)) source = fetch_tournament(client: source_anonymous_client, tournament_id: source.fetch(:id))
follower = wait_for_tournament_sync!(source_tournament_id: source.fetch(:id), follower = wait_for_tournament_sync!(source_tournament_id: source.fetch(:id), follower_tournament_id: follower.fetch(:id))
follower_tournament_id: follower.fetch(:id))
expect(playoff_token_list(source)).not_to be_nil expect(playoff_token_list(source)).not_to be_nil
expect(playoff_token_list(follower)).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) }) 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') 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_item = playoff_token_list(source).fetch(:team_action_items).first
source_token_update = source_owner_client.patch( source_token_update = source_owner_client.patch(
"/tournaments/#{source.fetch(:id)}/team_action_lists/#{playoff_token_list_key}/teams/#{source_token_item.dig( "/tournaments/#{source.fetch(:id)}/team_action_lists/#{playoff_token_list_key}/teams/#{source_token_item.dig(:team, :id)}",
:team, :id
)}",
body: { completed: true } body: { completed: true }
) )
expect(source_token_update[:status]).to eq(200) expect(source_token_update[:status]).to eq(200)
source = fetch_tournament(client: source_anonymous_client, tournament_id: source.fetch(:id)) source = fetch_tournament(client: source_anonymous_client, tournament_id: source.fetch(:id))
follower = wait_for_tournament_sync!(source_tournament_id: source.fetch(:id), follower = wait_for_tournament_sync!(source_tournament_id: source.fetch(:id), follower_tournament_id: follower.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?(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) 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)) follower_token_item = find_team_action_item(follower, team_name: source_token_item.dig(:team, :name))
locked_token_update = follower_owner_client.patch( locked_token_update = follower_owner_client.patch(
"/tournaments/#{follower.fetch(:id)}/team_action_lists/#{playoff_token_list_key}/teams/#{follower_token_item.dig( "/tournaments/#{follower.fetch(:id)}/team_action_lists/#{playoff_token_list_key}/teams/#{follower_token_item.dig(:team, :id)}",
:team, :id
)}",
body: { completed: false } body: { completed: false }
) )
expect(locked_token_update[:status]).to eq(423) expect(locked_token_update[:status]).to eq(423)
expect(locked_token_update.dig(:json, :error)).to eq('Tournament is in read only follower mode') 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', source_noise.concat(seed_unrelated_tournaments!(client: source_owner_client, prefix: 'Source Playoff Noise', range: 2..4))
range: 2..4)) follower_noise.concat(seed_unrelated_tournaments!(client: follower_owner_client, prefix: 'Follower Playoff Noise', range: 2..5))
follower_noise.concat(seed_unrelated_tournaments!(client: follower_owner_client, prefix: 'Follower Playoff Noise', exercise_unrelated_requests!(source_noise: source_noise, follower_noise: follower_noise)
range: 2..5))
exercise_unrelated_requests!(source_noise:, follower_noise:)
log_phase!('finish playoff bracket') log_phase!('finish playoff bracket')
finish_playoff_bracket!(source_id: source.fetch(:id)) 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') log_phase!('verify final follower state and takeover')
source = fetch_tournament(client: source_anonymous_client, tournament_id: source.fetch(:id)) source = fetch_tournament(client: source_anonymous_client, tournament_id: source.fetch(:id))
follower = wait_for_tournament_sync!(source_tournament_id: source.fetch(:id), follower = wait_for_tournament_sync!(source_tournament_id: source.fetch(:id), follower_tournament_id: follower.fetch(:id))
follower_tournament_id: follower.fetch(:id))
expect(tournament_signature(follower)).to eq(tournament_signature(source)) 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)}", disable_follower = follower_owner_client.patch("/tournaments/#{follower.fetch(:id)}", body: { read_only_mode: false })
body: { read_only_mode: false })
expect(disable_follower[:status]).to eq(200) expect(disable_follower[:status]).to eq(200)
follower_after_disable = fetch_tournament(client: follower_anonymous_client, tournament_id: follower.fetch(:id)) follower_after_disable = fetch_tournament(client: follower_anonymous_client, tournament_id: follower.fetch(:id))
writable_team = follower_after_disable.fetch(:teams).first writable_team = follower_after_disable.fetch(:teams).first
takeover_update = follower_owner_client.patch("/teams/#{writable_team.fetch(:id)}", takeover_update = follower_owner_client.patch("/teams/#{writable_team.fetch(:id)}", body: { name: "#{writable_team.fetch(:name)} takeover" })
body: { name: "#{writable_team.fetch(:name)} takeover" })
expect(takeover_update[:status]).to eq(200) expect(takeover_update[:status]).to eq(200)
end 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) def log_phase!(message)
puts "phase #{message}" puts "phase #{message}"
$stdout.flush $stdout.flush
end end
def login_client(base_url:, email:, password:) def login_client(base_url:, email:, password:)
client = TurniereE2E::ApiClient.new(base_url:) client = TurniereE2E::ApiClient.new(base_url: base_url)
response = client.login!(email:, password:) response = client.login!(email: email, password: password)
expect(response[:status]).to eq(200) expect(response[:status]).to eq(200)
expect(client.authenticated?).to eq(true) expect(client.authenticated?).to eq(true)
client client
end 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 = { payload = {
name: "#{name_prefix} #{unique_suffix}", name: "#{name_prefix} #{unique_suffix}",
description: 'Follower sync source tournament', 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, group_stage: true,
playoff_teams_amount: 8, playoff_teams_amount: playoff_teams_amount,
teams: 4.times.flat_map do |group_index| teams: group_count.times.flat_map do |group_index|
Array.new(4) do |seed_index| teams_per_group.times.map do |seed_index|
{ {
name: "#{name_prefix.tr(' ', '')}-G#{group_index + 1}-S#{seed_index + 1}", name: "#{name_prefix.tr(' ', '')}-G#{group_index + 1}-S#{seed_index + 1}",
group: group_index group: group_index
@ -270,34 +375,46 @@ RSpec.describe 'Tournament follower sync HTTP E2E' do
response = client.post('/tournaments', body: payload) response = client.post('/tournaments', body: payload)
expect(response[:status]).to eq(201) 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 end
def create_playoff_tournament(client:, public:, name_prefix:) def create_playoff_tournament(client:, public:, name_prefix:)
payload = { payload = {
name: "#{name_prefix} #{unique_suffix}", name: "#{name_prefix} #{unique_suffix}",
description: 'Unrelated playoff tournament', description: 'Unrelated playoff tournament',
public:, public: public,
teams: Array.new(4) do |index| 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}" } { name: "#{name_prefix.tr(' ', '')}-S#{index + 1}" }
end end
} }
response = client.post('/tournaments', body: payload) response = client.post('/tournaments', body: payload)
expect(response[:status]).to eq(201) 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 end
def create_follower_tournament(client:, public:, name_prefix:) def create_follower_tournament(client:, public:, name_prefix:)
response = client.post('/tournaments', body: { response = client.post('/tournaments', body: {
name: "#{name_prefix} #{unique_suffix}", name: "#{name_prefix} #{unique_suffix}",
description: 'Follower tournament', 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, read_only_mode: true,
sync_auth_token: sync_token sync_auth_token: sync_token
}) })
expect(response[:status]).to eq(201) 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 end
def fetch_tournament(client:, tournament_id:) def fetch_tournament(client:, tournament_id:)
@ -321,9 +438,9 @@ RSpec.describe 'Tournament follower sync HTTP E2E' do
amount = rand(range) amount = rand(range)
Array.new(amount) do |index| Array.new(amount) do |index|
if index.even? 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 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 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 }) update = client.patch("/tournaments/#{tournament.fetch(:id)}", body: { name: updated_name })
expect(update[:status]).to eq(200) 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 team = refreshed.fetch(:teams).first
return if team.nil? return if team.nil?
@ -363,21 +480,21 @@ RSpec.describe 'Tournament follower sync HTTP E2E' do
end end
def wait_for_tournament_sync!(source_tournament_id:, follower_tournament_id:, timeout: 20) def wait_for_tournament_sync!(source_tournament_id:, follower_tournament_id:, timeout: 20)
deadline = Time.zone.now + timeout deadline = Time.now + timeout
last_progress_at = Time.zone.now last_progress_at = Time.now
loop do loop do
source = fetch_tournament(client: source_anonymous_client, tournament_id: source_tournament_id) source = fetch_tournament(client: source_anonymous_client, tournament_id: source_tournament_id)
follower = fetch_tournament(client: follower_anonymous_client, tournament_id: follower_tournament_id) follower = fetch_tournament(client: follower_anonymous_client, tournament_id: follower_tournament_id)
return follower if tournament_signature(source) == tournament_signature(follower) 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}" puts "sync wait source=#{sync_progress_signature(source).inspect} follower=#{sync_progress_signature(follower).inspect}"
$stdout.flush $stdout.flush
last_progress_at = Time.zone.now last_progress_at = Time.now
end end
if Time.zone.now >= deadline if Time.now >= deadline
raise <<~ERROR raise <<~ERROR
Follower did not catch up within #{timeout}s Follower did not catch up within #{timeout}s
source_signature=#{tournament_signature(source).inspect} source_signature=#{tournament_signature(source).inspect}
@ -397,8 +514,8 @@ RSpec.describe 'Tournament follower sync HTTP E2E' do
end end
def synced_team_id_pairs(source:, follower:) def synced_team_id_pairs(source:, follower:)
source_by_name = source.fetch(:teams).index_by do |team| source_by_name = source.fetch(:teams).each_with_object({}) do |team, teams_by_name|
team.fetch(:name) teams_by_name[team.fetch(:name)] = team
end end
follower.fetch(:teams).filter_map do |team| follower.fetch(:teams).filter_map do |team|
source_team = source_by_name[team.fetch(:name)] 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' }) stage_finish = source_owner_client.patch("/stages/#{group_stage.fetch(:id)}", body: { state: 'finished' })
expect(stage_finish[:status]).to eq(422) expect(stage_finish[:status]).to eq(422)
expect(stage_finish.dig(:json, expect(stage_finish.dig(:json, :error)).to eq('Playoffs cannot be generated yet because a decider match is required for an advancing place')
: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 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| 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) expect(finish_decider[:status]).to eq(200)
end end
finish_stage_after_decider = source_owner_client.patch("/stages/#{group_stage.fetch(:id)}", finish_stage_after_decider = source_owner_client.patch("/stages/#{group_stage.fetch(:id)}", body: { state: 'finished' })
body: { state: 'finished' })
expect(finish_stage_after_decider[:status]).to eq(200) expect(finish_stage_after_decider[:status]).to eq(200)
end 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) play_match!(match_id: match.fetch(:id), first_score: score_a, second_score: score_b)
end 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:) def finish_playoff_bracket!(source_id:)
loop do loop do
tournament = fetch_tournament(client: source_anonymous_client, tournament_id: source_id) tournament = fetch_tournament(client: source_anonymous_client, tournament_id: source_id)
playoff_matches = tournament.fetch(:stages) playoff_matches = tournament.fetch(:stages)
.select { |stage| stage.fetch(:level) >= 0 } .select { |stage| stage.fetch(:level) >= 0 }
.flat_map { |stage| stage.fetch(:matches) } .flat_map { |stage| stage.fetch(:matches) }
.select do |match| .select { |match| %w[not_started in_progress].include?(match.fetch(:state)) && match.fetch(:match_scores).size == 2 }
%w[not_started
in_progress].include?(match.fetch(:state)) && match.fetch(:match_scores).size == 2
end
.sort_by { |match| [match.dig(:stage, :id) || 0, match.fetch(:position)] } .sort_by { |match| [match.dig(:stage, :id) || 0, match.fetch(:position)] }
break if playoff_matches.empty? 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) } ordered_scores = match.fetch(:json).fetch(:match_scores).sort_by { |score| score.fetch(:id) }
[first_score, second_score].each_with_index do |points, index| [first_score, second_score].each_with_index do |points, index|
response = source_owner_client.patch("/match_scores/#{ordered_scores[index].fetch(:id)}", response = source_owner_client.patch("/match_scores/#{ordered_scores[index].fetch(:id)}", body: { points: points })
body: { points: })
expect(response[:status]).to eq(200) expect(response[:status]).to eq(200)
end end
@ -529,13 +731,18 @@ RSpec.describe 'Tournament follower sync HTTP E2E' do
{ {
name: tournament.fetch(:name), name: tournament.fetch(:name),
public: tournament.fetch(:public), 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), playoff_teams_amount: tournament.fetch(:playoff_teams_amount),
instant_finalists_amount: tournament.fetch(:instant_finalists_amount), instant_finalists_amount: tournament.fetch(:instant_finalists_amount),
intermediate_round_participants_amount: tournament.fetch(:intermediate_round_participants_amount), intermediate_round_participants_amount: tournament.fetch(:intermediate_round_participants_amount),
timestamp: tournament[:timestamp], timestamp: tournament[:timestamp],
teams: tournament.fetch(:teams).map do |team| timer_mode: tournament[:timer_mode],
team.slice(:name, :advancing_from_group_stage) timer_reason: tournament[:timer_reason],
end.sort_by { |team| team.fetch(:name) }, 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| team_action_lists: tournament.fetch(:team_action_lists, []).sort_by { |list| list.fetch(:key) }.map do |list|
{ {
key: list.fetch(:key), key: list.fetch(:key),
@ -576,6 +783,9 @@ RSpec.describe 'Tournament follower sync HTTP E2E' do
state: match.fetch(:state), state: match.fetch(:state),
position: match.fetch(:position), position: match.fetch(:position),
decider: match.fetch(:decider), decider: match.fetch(:decider),
location: match[:location],
start_time: match[:start_time],
notes: match[:notes],
scores: match.fetch(:match_scores).map do |score| scores: match.fetch(:match_scores).map do |score|
{ {
team_name: score.dig(:team, :name), team_name: score.dig(:team, :name),
@ -627,6 +837,6 @@ RSpec.describe 'Tournament follower sync HTTP E2E' do
end end
def playoff_token_item_completed?(tournament, team_name:) 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
end end

View File

@ -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) } let(:follower_anonymous_client) { TurniereE2E::ApiClient.new(base_url: follower_base_url) }
it 'broadcasts follower websocket updates after source sync import' do it 'broadcasts follower websocket updates after source sync import' do
source = create_group_stage_tournament(client: source_client, base_url: source_base_url, source = create_group_stage_tournament(client: source_client, base_url: source_base_url, name_prefix: 'Ws Sync Source')
name_prefix: 'Ws Sync Source')
follower = create_follower_tournament(name_prefix: 'Ws Sync Follower') follower = create_follower_tournament(name_prefix: 'Ws Sync Follower')
configure_sync = source_client.patch("/tournaments/#{source.fetch(:id)}", body: { configure_sync = source_client.patch("/tournaments/#{source.fetch(:id)}", body: {
@ -62,7 +61,7 @@ RSpec.describe 'Tournament follower sync websocket HTTP E2E' do
end end
def login_client(base_url:) 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) response = client.login!(email: owner_email, password: owner_password)
expect(response[:status]).to eq(200) expect(response[:status]).to eq(200)
client client
@ -76,7 +75,7 @@ RSpec.describe 'Tournament follower sync websocket HTTP E2E' do
group_stage: true, group_stage: true,
playoff_teams_amount: 4, playoff_teams_amount: 4,
teams: 2.times.flat_map do |group_index| 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}", name: "#{name_prefix.tr(' ', '')}-G#{group_index + 1}-S#{seed_index + 1}",
group: group_index group: group_index
@ -85,7 +84,7 @@ RSpec.describe 'Tournament follower sync websocket HTTP E2E' do
end end
}) })
expect(response[:status]).to eq(201) 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 end
def create_follower_tournament(name_prefix:) def create_follower_tournament(name_prefix:)
@ -102,7 +101,7 @@ RSpec.describe 'Tournament follower sync websocket HTTP E2E' do
end end
def fetch_tournament(base_url:, tournament_id:) 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}") response = client.get("/tournaments/#{tournament_id}")
expect(response[:status]).to eq(200) expect(response[:status]).to eq(200)
response.fetch(:json) 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) } 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| [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) expect(score_update[:status]).to eq(200)
end end
@ -151,24 +150,24 @@ RSpec.describe 'Tournament follower sync websocket HTTP E2E' do
end end
def wait_for_list_presence!(tournament_id, timeout: 20) def wait_for_list_presence!(tournament_id, timeout: 20)
deadline = Time.zone.now + timeout deadline = Time.now + timeout
loop do 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? 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 sleep 0.5
end end
end end
def wait_for_team_action_list_state!(cable_client, team_name:, completed:, timeout: 20) 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 loop do
payload = cable_client.wait_for_payload!(timeout: [deadline - Time.zone.now, 0.1].max) payload = cable_client.wait_for_payload!(timeout: [deadline - Time.now, 0.1].max)
return payload if team_action_item_completed?(payload, team_name:) == completed 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
end end

View File

@ -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(:base_url) { ENV.fetch('TURNIERE_E2E_BASE_URL', 'http://127.0.0.1:3000') }
let(:runner) do let(:runner) do
TurniereE2E::ScenarioRunner.new( TurniereE2E::ScenarioRunner.new(
base_url:, base_url: base_url,
email: ENV.fetch('TURNIERE_E2E_EMAIL'), email: ENV.fetch('TURNIERE_E2E_EMAIL'),
password: ENV.fetch('TURNIERE_E2E_PASSWORD'), password: ENV.fetch('TURNIERE_E2E_PASSWORD'),
username: ENV.fetch('TURNIERE_E2E_USERNAME', nil) username: ENV['TURNIERE_E2E_USERNAME']
) )
end end
@ -31,7 +31,7 @@ RSpec.describe 'Tournament lifecycle HTTP E2E' do
expect(result.dig(:checkpoints, :statistics, :group_scores_count)).to eq(16) expect(result.dig(:checkpoints, :statistics, :group_scores_count)).to eq(16)
playoffs_created = result.dig(:checkpoints, :playoffs_created, :stage_summaries) 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) expect(playoff_entry_stage[:match_states].count).to eq(4)
final_attempt = result.dig(:checkpoints, :final_finish_attempted, :finish_attempt) 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) expect(created[:intermediate_round_participants_amount]).to eq(2)
playoff_entry_stage = result.dig(:checkpoints, :playoffs_created, :stage_summaries) 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] } .max_by { |stage| stage[:level] }
expect(playoff_entry_stage[:state]).to eq('intermediate_stage') expect(playoff_entry_stage[:state]).to eq('intermediate_stage')
expect(playoff_entry_stage[:match_states].count('single_team')).to eq(3) 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]) .find { |stage| stage[:level] == 0 }[:match_states]).to eq(%w[finished finished])
end 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], won: [1, 2],
lost: [2, 1], lost: [2, 1],
@ -76,8 +97,7 @@ RSpec.describe 'Tournament lifecycle HTTP E2E' do
expect(positions.fetch('G1-S2')).to eq(expected_positions[1]) expect(positions.fetch('G1-S2')).to eq(expected_positions[1])
if direct_result == :drawn if direct_result == :drawn
expect(ordered_scores[0].slice(:team_name, :position, :group_points, :difference_in_points, :scored_points, expect(ordered_scores[0].slice(:team_name, :position, :group_points, :difference_in_points, :scored_points, :received_points))
:received_points))
.to eq( .to eq(
team_name: 'G1-S1', team_name: 'G1-S1',
position: 1, position: 1,
@ -86,8 +106,7 @@ RSpec.describe 'Tournament lifecycle HTTP E2E' do
scored_points: 4, scored_points: 4,
received_points: 1 received_points: 1
) )
expect(ordered_scores[1].slice(:team_name, :position, :group_points, :difference_in_points, :scored_points, expect(ordered_scores[1].slice(:team_name, :position, :group_points, :difference_in_points, :scored_points, :received_points))
:received_points))
.to eq( .to eq(
team_name: 'G1-S2', team_name: 'G1-S2',
position: 1, position: 1,
@ -98,10 +117,9 @@ RSpec.describe 'Tournament lifecycle HTTP E2E' do
) )
else else
expected_first_group_points = direct_result == :lost ? 9 : 6 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, expect(ordered_scores[0].slice(:team_name, :position, :group_points, :difference_in_points, :scored_points, :received_points))
:received_points))
.to eq( .to eq(
team_name: expected_positions[0] == 1 ? 'G1-S1' : 'G1-S2', team_name: expected_positions[0] == 1 ? 'G1-S1' : 'G1-S2',
position: 1, position: 1,
@ -110,8 +128,7 @@ RSpec.describe 'Tournament lifecycle HTTP E2E' do
scored_points: 5, scored_points: 5,
received_points: 2 received_points: 2
) )
expect(ordered_scores[1].slice(:team_name, :position, :group_points, :difference_in_points, :scored_points, expect(ordered_scores[1].slice(:team_name, :position, :group_points, :difference_in_points, :scored_points, :received_points))
:received_points))
.to eq( .to eq(
team_name: expected_positions[0] == 1 ? 'G1-S2' : 'G1-S1', team_name: expected_positions[0] == 1 ? 'G1-S2' : 'G1-S1',
position: 2, position: 2,
@ -126,11 +143,28 @@ RSpec.describe 'Tournament lifecycle HTTP E2E' do
[2, 3, 5, 8].each do |team_count| [2, 3, 5, 8].each do |team_count|
it "drives a #{team_count}-team playoff-only tournament over HTTP to completion" do 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) completed_stages = result.dig(:checkpoints, :completed, :stage_summaries)
final_stage = completed_stages.find { |stage| stage[:level] == 0 } final_stage = completed_stages.find { |stage| stage[:level] == 0 }
expect(final_stage[:match_states]).to eq(team_count > 3 ? %w[finished finished] : ['finished']) expect(final_stage[:match_states]).to eq(team_count > 3 ? %w[finished finished] : ['finished'])
end end
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 end

View File

@ -15,20 +15,17 @@ RSpec.describe 'Tournament rendering HTTP E2E' do
let(:expect_profiling) { ENV.fetch('TURNIERE_E2E_EXPECT_PROFILING', 'true') == 'true' } let(:expect_profiling) { ENV.fetch('TURNIERE_E2E_EXPECT_PROFILING', 'true') == 'true' }
let(:runner) do let(:runner) do
TurniereE2E::ScenarioRunner.new( TurniereE2E::ScenarioRunner.new(
base_url:, base_url: base_url,
email: ENV.fetch('TURNIERE_E2E_EMAIL'), email: ENV.fetch('TURNIERE_E2E_EMAIL'),
password: ENV.fetch('TURNIERE_E2E_PASSWORD'), password: ENV.fetch('TURNIERE_E2E_PASSWORD'),
username: ENV.fetch('TURNIERE_E2E_USERNAME', nil) username: ENV['TURNIERE_E2E_USERNAME']
) )
end end
[ [
{ group_count: 8, teams_per_group: 4, playoff_teams_amount: 8, max_request_duration_ms: 1000, { group_count: 8, teams_per_group: 4, playoff_teams_amount: 8, max_request_duration_ms: 1000, max_server_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: 64, teams_per_group: 6, playoff_teams_amount: 64, max_request_duration_ms: 5000, { group_count: 4, teams_per_group: 32, playoff_teams_amount: 4, max_request_duration_ms: 5000, max_server_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| ].each do |scenario|
it "creates #{scenario[:group_count]}x#{scenario[:teams_per_group]} via API and renders within budget" do it "creates #{scenario[:group_count]}x#{scenario[:teams_per_group]} via API and renders within budget" do
result = runner.run_group_stage_render_profile( result = runner.run_group_stage_render_profile(

Some files were not shown because too many files have changed in this diff Show More