fix: refresh RuboCop cleanup
This commit is contained in:
commit
8951070a15
|
|
@ -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:
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -1 +1 @@
|
||||||
3.1.2
|
4.0.3
|
||||||
|
|
|
||||||
34
Dockerfile
34
Dockerfile
|
|
@ -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
12
Gemfile
|
|
@ -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]
|
||||||
|
|
|
||||||
521
Gemfile.lock
521
Gemfile.lock
|
|
@ -1,326 +1,407 @@
|
||||||
GIT
|
|
||||||
remote: https://github.com/Thor77/devise_token_auth
|
|
||||||
revision: 30ba21add5c87b2964b909c6fc5cd18cfeae2191
|
|
||||||
branch: fix-api-only-mode
|
|
||||||
specs:
|
|
||||||
devise_token_auth (1.2.0)
|
|
||||||
bcrypt (~> 3.0)
|
|
||||||
devise (> 3.5.2, < 5)
|
|
||||||
rails (>= 4.2.0, < 7.1)
|
|
||||||
|
|
||||||
GEM
|
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
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,15 @@
|
||||||
|
# frozen_string_literal: true
|
||||||
|
|
||||||
|
class AccountsController < ApplicationController
|
||||||
|
before_action :set_account, only: %i[show]
|
||||||
|
|
||||||
|
def show
|
||||||
|
render json: @account, serializer: AccountProfileSerializer, scope: current_user
|
||||||
|
end
|
||||||
|
|
||||||
|
private
|
||||||
|
|
||||||
|
def set_account
|
||||||
|
@account = User.includes(:tournaments, player_teams: :tournament).find(params[:id])
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
@ -3,11 +3,14 @@
|
||||||
class ApplicationController < ActionController::API
|
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
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,8 @@
|
||||||
|
# frozen_string_literal: true
|
||||||
|
|
||||||
|
class MetricsController < ApplicationController
|
||||||
|
def index
|
||||||
|
render plain: MetricsRegistry.render_prometheus,
|
||||||
|
content_type: 'text/plain; version=0.0.4'
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
@ -13,64 +13,138 @@ class StagesController < ApplicationController
|
||||||
|
|
||||||
# PUT /stages/1
|
# 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
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,18 @@
|
||||||
|
# frozen_string_literal: true
|
||||||
|
|
||||||
|
class TournamentTransactionLogEntriesController < ApplicationController
|
||||||
|
before_action :set_tournament
|
||||||
|
before_action :authenticate_user!
|
||||||
|
before_action -> { require_owner! @tournament.owner }
|
||||||
|
|
||||||
|
def index
|
||||||
|
entries = @tournament.transaction_log_entries.includes(:user).order(:sequence)
|
||||||
|
render json: entries, each_serializer: TournamentTransactionLogEntrySerializer
|
||||||
|
end
|
||||||
|
|
||||||
|
private
|
||||||
|
|
||||||
|
def set_tournament
|
||||||
|
@tournament = Tournament.find(params[:id])
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
@ -12,15 +12,19 @@ class TournamentsController < ApplicationController
|
||||||
end
|
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
|
|
||||||
elsif timer_mode == 'countup'
|
|
||||||
params[:timestamp] = Time.zone.now
|
|
||||||
elsif timer_mode.present?
|
|
||||||
render json: { error: 'timer_mode requires timestamp, timestamp_seconds, or clear_timer' },
|
|
||||||
status: :unprocessable_entity
|
|
||||||
else
|
|
||||||
render json: { error: 'timestamp, timestamp_seconds, or clear_timer is required' },
|
|
||||||
status: :unprocessable_entity
|
|
||||||
end
|
end
|
||||||
|
|
||||||
|
def normalize_timer_reason_params
|
||||||
|
params[:timer_reason] = params[:timer_reason].presence
|
||||||
|
params[:timer_reason_text] = timer_reason_text_param
|
||||||
|
end
|
||||||
|
|
||||||
|
def timer_reason_text_param
|
||||||
|
return nil unless params.key?(:timer_reason_text)
|
||||||
|
|
||||||
|
params[:timer_reason_text].to_s.strip.presence
|
||||||
|
end
|
||||||
|
|
||||||
|
def validate_clear_timer_params(timestamp, timestamp_seconds, timer_mode, timer_reason, timer_reason_text)
|
||||||
|
if timestamp.present? || timestamp_seconds.present? || timer_mode.present? ||
|
||||||
|
timer_reason.present? || timer_reason_text.present?
|
||||||
|
return render_timer_validation_error('clear_timer cannot be combined with timer values or timer reason')
|
||||||
|
end
|
||||||
|
|
||||||
|
params[:timestamp] = nil
|
||||||
|
params[:timer_mode] = nil
|
||||||
|
params[:timer_reason] = nil
|
||||||
|
params[:timer_reason_text] = nil
|
||||||
|
end
|
||||||
|
|
||||||
|
def validate_timer_reason_params(
|
||||||
|
timer_reason,
|
||||||
|
timer_reason_text,
|
||||||
|
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
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,6 @@
|
||||||
|
# frozen_string_literal: true
|
||||||
|
|
||||||
|
class TeamPlayer < ApplicationRecord
|
||||||
|
belongs_to :team
|
||||||
|
belongs_to :player, class_name: 'User'
|
||||||
|
end
|
||||||
|
|
@ -1,26 +1,38 @@
|
||||||
# frozen_string_literal: true
|
# 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
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,10 @@
|
||||||
|
# frozen_string_literal: true
|
||||||
|
|
||||||
|
class TournamentTransactionLogEntry < ApplicationRecord
|
||||||
|
belongs_to :tournament
|
||||||
|
belongs_to :user, optional: true
|
||||||
|
|
||||||
|
validates :sequence, presence: true, numericality: { only_integer: true, greater_than: 0 }
|
||||||
|
validates :action, presence: true
|
||||||
|
validates :message, presence: true
|
||||||
|
end
|
||||||
|
|
@ -11,5 +11,38 @@ class User < ApplicationRecord
|
||||||
validates :username, presence: true, uniqueness: { case_sensitive: false }
|
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
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,60 @@
|
||||||
|
# frozen_string_literal: true
|
||||||
|
|
||||||
|
class AccountProfileSerializer < AccountSummarySerializer
|
||||||
|
attributes :tournaments, :created_tournaments, :teams
|
||||||
|
|
||||||
|
def tournaments
|
||||||
|
visible_played_tournaments.map { |tournament| tournament_summary(tournament) }
|
||||||
|
end
|
||||||
|
|
||||||
|
def created_tournaments
|
||||||
|
visible_created_tournaments.map { |tournament| tournament_summary(tournament) }
|
||||||
|
end
|
||||||
|
|
||||||
|
def teams
|
||||||
|
visible_player_teams.map do |team|
|
||||||
|
{
|
||||||
|
id: team.id,
|
||||||
|
name: team.name,
|
||||||
|
tournament: tournament_summary(team.tournament)
|
||||||
|
}
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
private
|
||||||
|
|
||||||
|
def visible_played_tournaments
|
||||||
|
visible_tournaments(object.played_tournaments.order(:created_at))
|
||||||
|
end
|
||||||
|
|
||||||
|
def visible_created_tournaments
|
||||||
|
visible_tournaments(object.tournaments.order(:created_at))
|
||||||
|
end
|
||||||
|
|
||||||
|
def visible_player_teams
|
||||||
|
object.player_teams.includes(:tournament).order(:created_at).select do |team|
|
||||||
|
profile_owner? || team.tournament&.public?
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
def visible_tournaments(scope)
|
||||||
|
return scope if profile_owner?
|
||||||
|
|
||||||
|
scope.where(public: true)
|
||||||
|
end
|
||||||
|
|
||||||
|
def profile_owner?
|
||||||
|
scope == object
|
||||||
|
end
|
||||||
|
|
||||||
|
def tournament_summary(tournament)
|
||||||
|
return nil if tournament.nil?
|
||||||
|
|
||||||
|
{
|
||||||
|
id: tournament.id,
|
||||||
|
name: tournament.name,
|
||||||
|
code: tournament.code,
|
||||||
|
public: tournament.public
|
||||||
|
}
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
@ -0,0 +1,9 @@
|
||||||
|
# frozen_string_literal: true
|
||||||
|
|
||||||
|
class AccountSummarySerializer < ApplicationSerializer
|
||||||
|
attributes :name, :username
|
||||||
|
|
||||||
|
def name
|
||||||
|
object.username
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
@ -1,7 +1,7 @@
|
||||||
# frozen_string_literal: true
|
# 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
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,18 @@
|
||||||
|
# frozen_string_literal: true
|
||||||
|
|
||||||
|
class TournamentTransactionLogEntrySerializer < ApplicationSerializer
|
||||||
|
attributes :id, :sequence, :action, :message, :metadata
|
||||||
|
|
||||||
|
attribute :created_at do
|
||||||
|
object.created_at&.iso8601
|
||||||
|
end
|
||||||
|
|
||||||
|
attribute :user do
|
||||||
|
next nil if object.user.nil?
|
||||||
|
|
||||||
|
{
|
||||||
|
id: object.user.id,
|
||||||
|
username: object.user.username
|
||||||
|
}
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
@ -0,0 +1,212 @@
|
||||||
|
# frozen_string_literal: true
|
||||||
|
|
||||||
|
class MetricsRegistry
|
||||||
|
HTTP_LABELS = %i[method controller action status].freeze
|
||||||
|
REQUEST_DURATION_BUCKETS = [0.005, 0.01, 0.025, 0.05, 0.1, 0.25, 0.5, 1, 2.5, 5, 10].freeze
|
||||||
|
STARTED_AT = Process.clock_gettime(Process::CLOCK_MONOTONIC)
|
||||||
|
|
||||||
|
class << self
|
||||||
|
def record_http_request(method:, controller:, action:, status:, duration:)
|
||||||
|
labels = http_labels(method:, controller:, action:, status:)
|
||||||
|
|
||||||
|
synchronize do
|
||||||
|
@http_requests[labels] += 1
|
||||||
|
histogram = @http_request_duration[labels]
|
||||||
|
histogram[:count] += 1
|
||||||
|
histogram[:sum] += duration.to_f
|
||||||
|
REQUEST_DURATION_BUCKETS.each do |bucket|
|
||||||
|
histogram[:buckets][bucket] += 1 if duration.to_f <= bucket
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
def action_cable_connected
|
||||||
|
synchronize do
|
||||||
|
@action_cable_connections += 1
|
||||||
|
@action_cable_connections_total += 1
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
def action_cable_disconnected
|
||||||
|
synchronize do
|
||||||
|
@action_cable_connections = [@action_cable_connections - 1, 0].max
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
def render_prometheus
|
||||||
|
snapshot = snapshot_metrics
|
||||||
|
|
||||||
|
PrometheusRenderer.new(snapshot).render
|
||||||
|
end
|
||||||
|
|
||||||
|
def reset!
|
||||||
|
@mutex = Mutex.new
|
||||||
|
@http_requests = Hash.new(0)
|
||||||
|
@http_request_duration = Hash.new do |hash, labels|
|
||||||
|
hash[labels] = {
|
||||||
|
count: 0,
|
||||||
|
sum: 0.0,
|
||||||
|
buckets: REQUEST_DURATION_BUCKETS.index_with(0)
|
||||||
|
}
|
||||||
|
end
|
||||||
|
@action_cable_connections = 0
|
||||||
|
@action_cable_connections_total = 0
|
||||||
|
end
|
||||||
|
|
||||||
|
private
|
||||||
|
|
||||||
|
def synchronize(&block)
|
||||||
|
reset! if @mutex.nil?
|
||||||
|
|
||||||
|
@mutex.synchronize(&block)
|
||||||
|
end
|
||||||
|
|
||||||
|
def snapshot_metrics
|
||||||
|
synchronize do
|
||||||
|
{
|
||||||
|
http_requests: @http_requests.dup,
|
||||||
|
http_request_duration: @http_request_duration.transform_values do |histogram|
|
||||||
|
{
|
||||||
|
count: histogram.fetch(:count),
|
||||||
|
sum: histogram.fetch(:sum),
|
||||||
|
buckets: histogram.fetch(:buckets).dup
|
||||||
|
}
|
||||||
|
end,
|
||||||
|
action_cable_connections: @action_cable_connections,
|
||||||
|
action_cable_connections_total: @action_cable_connections_total,
|
||||||
|
uptime_seconds: Process.clock_gettime(Process::CLOCK_MONOTONIC) - STARTED_AT,
|
||||||
|
active_record_pool: active_record_pool_stats,
|
||||||
|
git_commit_sha: ENV.fetch('GIT_COMMIT_SHA', 'unknown')
|
||||||
|
}
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
def http_labels(method:, controller:, action:, status:)
|
||||||
|
{
|
||||||
|
method: method.to_s.upcase,
|
||||||
|
controller: controller.to_s,
|
||||||
|
action: action.to_s,
|
||||||
|
status: status.to_i.to_s
|
||||||
|
}.freeze
|
||||||
|
end
|
||||||
|
|
||||||
|
def active_record_pool_stats
|
||||||
|
return {} unless defined?(ActiveRecord::Base)
|
||||||
|
|
||||||
|
ActiveRecord::Base.connection_pool.stat
|
||||||
|
rescue StandardError
|
||||||
|
{}
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
class PrometheusRenderer
|
||||||
|
def initialize(snapshot)
|
||||||
|
@snapshot = snapshot
|
||||||
|
@lines = []
|
||||||
|
end
|
||||||
|
|
||||||
|
def render
|
||||||
|
render_build_info
|
||||||
|
render_http_requests
|
||||||
|
render_http_request_duration
|
||||||
|
render_action_cable_connections
|
||||||
|
render_process_metrics
|
||||||
|
render_active_record_pool
|
||||||
|
|
||||||
|
"#{lines.join("\n")}\n"
|
||||||
|
end
|
||||||
|
|
||||||
|
private
|
||||||
|
|
||||||
|
attr_reader :snapshot, :lines
|
||||||
|
|
||||||
|
def render_build_info
|
||||||
|
help('turniere_build_info', 'Build metadata for this process.')
|
||||||
|
type('turniere_build_info', 'gauge')
|
||||||
|
sample('turniere_build_info', 1, git_commit_sha: snapshot.fetch(:git_commit_sha))
|
||||||
|
end
|
||||||
|
|
||||||
|
def render_http_requests
|
||||||
|
help('turniere_http_requests_total', 'Total HTTP requests handled by Rails controllers.')
|
||||||
|
type('turniere_http_requests_total', 'counter')
|
||||||
|
snapshot.fetch(:http_requests).each do |labels, count|
|
||||||
|
sample('turniere_http_requests_total', count, labels)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
def render_http_request_duration
|
||||||
|
help('turniere_http_request_duration_seconds', 'HTTP request duration in seconds.')
|
||||||
|
type('turniere_http_request_duration_seconds', 'histogram')
|
||||||
|
snapshot.fetch(:http_request_duration).each do |labels, histogram|
|
||||||
|
histogram.fetch(:buckets).each do |bucket, count|
|
||||||
|
sample('turniere_http_request_duration_seconds_bucket', count, labels.merge(le: format_number(bucket)))
|
||||||
|
end
|
||||||
|
sample('turniere_http_request_duration_seconds_bucket', histogram.fetch(:count), labels.merge(le: '+Inf'))
|
||||||
|
sample('turniere_http_request_duration_seconds_sum', histogram.fetch(:sum), labels)
|
||||||
|
sample('turniere_http_request_duration_seconds_count', histogram.fetch(:count), labels)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
def render_action_cable_connections
|
||||||
|
help('turniere_action_cable_connections', 'Currently open ActionCable websocket connections.')
|
||||||
|
type('turniere_action_cable_connections', 'gauge')
|
||||||
|
sample('turniere_action_cable_connections', snapshot.fetch(:action_cable_connections))
|
||||||
|
|
||||||
|
help('turniere_action_cable_connections_total', 'Total ActionCable websocket connections opened.')
|
||||||
|
type('turniere_action_cable_connections_total', 'counter')
|
||||||
|
sample('turniere_action_cable_connections_total', snapshot.fetch(:action_cable_connections_total))
|
||||||
|
end
|
||||||
|
|
||||||
|
def render_process_metrics
|
||||||
|
help('turniere_process_uptime_seconds', 'Process uptime in seconds.')
|
||||||
|
type('turniere_process_uptime_seconds', 'gauge')
|
||||||
|
sample('turniere_process_uptime_seconds', snapshot.fetch(:uptime_seconds))
|
||||||
|
|
||||||
|
help('turniere_ruby_threads', 'Ruby threads visible in this process.')
|
||||||
|
type('turniere_ruby_threads', 'gauge')
|
||||||
|
sample('turniere_ruby_threads', Thread.list.count)
|
||||||
|
end
|
||||||
|
|
||||||
|
def render_active_record_pool
|
||||||
|
help('turniere_active_record_connection_pool', 'ActiveRecord connection pool state.')
|
||||||
|
type('turniere_active_record_connection_pool', 'gauge')
|
||||||
|
snapshot.fetch(:active_record_pool).each do |state, value|
|
||||||
|
sample('turniere_active_record_connection_pool', value, state:)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
def help(name, text)
|
||||||
|
lines << "# HELP #{name} #{text}"
|
||||||
|
end
|
||||||
|
|
||||||
|
def type(name, type)
|
||||||
|
lines << "# TYPE #{name} #{type}"
|
||||||
|
end
|
||||||
|
|
||||||
|
def sample(name, value, labels = {})
|
||||||
|
lines << "#{name}#{labels_string(labels)} #{format_number(value)}"
|
||||||
|
end
|
||||||
|
|
||||||
|
def labels_string(labels)
|
||||||
|
return '' if labels.empty?
|
||||||
|
|
||||||
|
formatted = labels.map do |key, value|
|
||||||
|
%(#{key}="#{escape_label_value(value)}")
|
||||||
|
end
|
||||||
|
|
||||||
|
"{#{formatted.join(',')}}"
|
||||||
|
end
|
||||||
|
|
||||||
|
def escape_label_value(value)
|
||||||
|
value.to_s.gsub('\\', '\\\\\\').gsub("\n", '\\n').gsub('"', '\"')
|
||||||
|
end
|
||||||
|
|
||||||
|
def format_number(value)
|
||||||
|
return value.to_s if value.is_a?(Integer)
|
||||||
|
|
||||||
|
format('%.12g', value)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
MetricsRegistry.reset!
|
||||||
|
|
@ -0,0 +1,60 @@
|
||||||
|
# frozen_string_literal: true
|
||||||
|
|
||||||
|
class TournamentGroupMatchStarter
|
||||||
|
class << self
|
||||||
|
def upcoming_matches(tournament)
|
||||||
|
group_matches = not_started_group_matches(tournament)
|
||||||
|
lowest_position = group_matches.minimum(:position)
|
||||||
|
return [] if lowest_position.nil?
|
||||||
|
|
||||||
|
selected_lowest_matches(group_matches, lowest_position)
|
||||||
|
end
|
||||||
|
|
||||||
|
def start_next!(tournament, logger: Rails.logger, user: nil)
|
||||||
|
started_matches = []
|
||||||
|
|
||||||
|
Match.transaction do
|
||||||
|
TournamentTransactionLogger.lock_tournament!(tournament)
|
||||||
|
started_matches = upcoming_matches(tournament)
|
||||||
|
started_matches.each { |match| match.update!(state: :in_progress) }
|
||||||
|
recalculate_group_scores!(started_matches, logger:)
|
||||||
|
TournamentTransactionLogger.record_group_matches_started!(
|
||||||
|
tournament: tournament,
|
||||||
|
user: user,
|
||||||
|
matches: started_matches
|
||||||
|
)
|
||||||
|
end
|
||||||
|
|
||||||
|
started_matches
|
||||||
|
end
|
||||||
|
|
||||||
|
private
|
||||||
|
|
||||||
|
def not_started_group_matches(tournament)
|
||||||
|
group_stage = tournament.group_stage
|
||||||
|
return Match.none if group_stage.nil?
|
||||||
|
|
||||||
|
group_ids = group_stage.groups.ids
|
||||||
|
return Match.none if group_ids.empty?
|
||||||
|
|
||||||
|
Match.where(group_id: group_ids).not_started
|
||||||
|
end
|
||||||
|
|
||||||
|
def selected_lowest_matches(group_matches, lowest_position)
|
||||||
|
group_matches.where(position: lowest_position)
|
||||||
|
.includes(:group)
|
||||||
|
.group_by(&:group_id)
|
||||||
|
.values
|
||||||
|
.map { |matches| matches.min_by(&:id) }
|
||||||
|
.sort_by { |match| [match.position, match.group.number, match.id] }
|
||||||
|
end
|
||||||
|
|
||||||
|
def recalculate_group_scores!(matches, logger:)
|
||||||
|
matches.map(&:group).uniq.each do |group|
|
||||||
|
next if UpdateGroupsGroupScoresAndSave.call(group:).success?
|
||||||
|
|
||||||
|
logger.warn "Updating groups group score failed for #{group}"
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
@ -79,12 +79,7 @@ class TournamentMatchesPayload
|
||||||
end
|
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)
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,56 @@
|
||||||
|
# frozen_string_literal: true
|
||||||
|
|
||||||
|
class TournamentPlayoffDeletion
|
||||||
|
DELETABLE_MATCH_STATES = %w[not_started not_ready single_team].freeze
|
||||||
|
|
||||||
|
Result = Struct.new(:success, :error, keyword_init: true) do
|
||||||
|
def success?
|
||||||
|
success
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
def self.call(tournament:)
|
||||||
|
new(tournament).call
|
||||||
|
end
|
||||||
|
|
||||||
|
def initialize(tournament)
|
||||||
|
@tournament = tournament
|
||||||
|
end
|
||||||
|
|
||||||
|
def call
|
||||||
|
group_stage = tournament.group_stage
|
||||||
|
return failure('Only group-stage playoffs can be deleted') if group_stage.nil?
|
||||||
|
|
||||||
|
playoff_stages = tournament.stages.reload.select { |stage| stage.level >= 0 }
|
||||||
|
return failure('Playoffs have not been generated') if playoff_stages.empty?
|
||||||
|
return failure('Playoffs cannot be deleted after matches have started or scores changed') unless playoffs_deletable?(playoff_stages)
|
||||||
|
|
||||||
|
playoff_stages.each(&:destroy!)
|
||||||
|
group_stage.update!(state: :in_progress)
|
||||||
|
success
|
||||||
|
end
|
||||||
|
|
||||||
|
private
|
||||||
|
|
||||||
|
attr_reader :tournament
|
||||||
|
|
||||||
|
def playoffs_deletable?(playoff_stages)
|
||||||
|
playoff_stages.flat_map(&:matches).all? do |match|
|
||||||
|
DELETABLE_MATCH_STATES.include?(match.state) && zero_scores?(match)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
def zero_scores?(match)
|
||||||
|
match.match_scores.all? do |score|
|
||||||
|
score.points.to_i.zero? && score.hidden_points.to_i.zero?
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
def success
|
||||||
|
Result.new(success: true)
|
||||||
|
end
|
||||||
|
|
||||||
|
def failure(error)
|
||||||
|
Result.new(success: false, error: error)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
@ -22,8 +22,12 @@ class TournamentSnapshotBuilder
|
||||||
|
|
||||||
def build
|
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
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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 => {
|
||||||
|
|
|
||||||
|
|
@ -7,7 +7,9 @@ class TournamentTimerBroadcastPayload
|
||||||
type: 'timer.updated',
|
type: 'timer.updated',
|
||||||
tournament_id: tournament.id,
|
tournament_id: tournament.id,
|
||||||
timestamp: tournament.timestamp&.iso8601,
|
timestamp: tournament.timestamp&.iso8601,
|
||||||
timer_mode: tournament.timer_mode
|
timer_mode: tournament.timer_mode,
|
||||||
|
timer_reason: tournament.timer_reason,
|
||||||
|
timer_reason_text: tournament.timer_reason_text
|
||||||
}
|
}
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,345 @@
|
||||||
|
# frozen_string_literal: true
|
||||||
|
|
||||||
|
class TournamentTransactionLogger
|
||||||
|
class << self
|
||||||
|
def lock_tournament!(tournament)
|
||||||
|
raise ArgumentError, 'tournament is required' if tournament.nil?
|
||||||
|
|
||||||
|
raise 'Tournament transaction log writes must happen inside a transaction' unless ActiveRecord::Base.connection.transaction_open?
|
||||||
|
|
||||||
|
# Lock a fresh row instance so callers keep saved_change_to_* state for
|
||||||
|
# after_commit callbacks on the object they just updated.
|
||||||
|
Tournament.lock.find(tournament.id)
|
||||||
|
end
|
||||||
|
|
||||||
|
def record!(tournament:, user:, action:, message:, metadata: {})
|
||||||
|
retries = 0
|
||||||
|
|
||||||
|
begin
|
||||||
|
Tournament.transaction(requires_new: true) do
|
||||||
|
lock_tournament!(tournament)
|
||||||
|
TournamentTransactionLogEntry.create!(
|
||||||
|
tournament: tournament,
|
||||||
|
user: user,
|
||||||
|
sequence: next_sequence_for(tournament),
|
||||||
|
action: action,
|
||||||
|
message: message,
|
||||||
|
metadata: metadata.deep_stringify_keys
|
||||||
|
)
|
||||||
|
end
|
||||||
|
rescue ActiveRecord::RecordNotUnique
|
||||||
|
retries += 1
|
||||||
|
retry if retries < 3
|
||||||
|
|
||||||
|
raise
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
def record_tournament_created!(tournament:, user:)
|
||||||
|
record!(
|
||||||
|
tournament: tournament,
|
||||||
|
user: user,
|
||||||
|
action: 'tournament.created',
|
||||||
|
message: "#{user_label(user)} created tournament #{tournament.name}(#{tournament.id})",
|
||||||
|
metadata: {
|
||||||
|
tournament_id: tournament.id,
|
||||||
|
tournament_name: tournament.name
|
||||||
|
}
|
||||||
|
)
|
||||||
|
end
|
||||||
|
|
||||||
|
def record_timer_changed!(tournament:, user:, old_timer:, new_timer:)
|
||||||
|
record!(
|
||||||
|
tournament: tournament,
|
||||||
|
user: user,
|
||||||
|
action: 'tournament.timer_changed',
|
||||||
|
message: "#{user_label(user)} changed tournament timer for #{tournament.name}(#{tournament.id})",
|
||||||
|
metadata: {
|
||||||
|
old_timer: old_timer,
|
||||||
|
new_timer: new_timer
|
||||||
|
}
|
||||||
|
)
|
||||||
|
end
|
||||||
|
|
||||||
|
def record_match_state_change!(match:, user:, old_state:, new_state:, details: {})
|
||||||
|
match.reload
|
||||||
|
stage_type = match.group_match? ? 'groupstage' : 'playoffs'
|
||||||
|
group_scores = details.fetch(:group_scores, [])
|
||||||
|
advanced_match_ids = details.fetch(:advanced_match_ids, [])
|
||||||
|
metadata = match_metadata(match).merge(
|
||||||
|
old_state: old_state,
|
||||||
|
new_state: new_state,
|
||||||
|
stage_type: stage_type,
|
||||||
|
winner_team_id: match.winner&.id,
|
||||||
|
advanced_match_ids: advanced_match_ids,
|
||||||
|
group_scores: group_scores_metadata(group_scores)
|
||||||
|
)
|
||||||
|
|
||||||
|
record!(
|
||||||
|
tournament: match.tournament,
|
||||||
|
user: user,
|
||||||
|
action: 'match.state_changed',
|
||||||
|
message: match_state_message(
|
||||||
|
match: match,
|
||||||
|
user: user,
|
||||||
|
new_state: new_state,
|
||||||
|
details: details.merge(stage_type: stage_type)
|
||||||
|
),
|
||||||
|
metadata: metadata
|
||||||
|
)
|
||||||
|
end
|
||||||
|
|
||||||
|
def record_match_score_update!(match_score:, user:, changes:, group_scores: [])
|
||||||
|
return if changes.empty?
|
||||||
|
|
||||||
|
match_score.reload
|
||||||
|
match = match_score.match
|
||||||
|
metadata = match_metadata(match).merge(
|
||||||
|
match_score_id: match_score.id,
|
||||||
|
team_id: match_score.team_id,
|
||||||
|
changes: changes,
|
||||||
|
group_scores: group_scores_metadata(group_scores)
|
||||||
|
)
|
||||||
|
|
||||||
|
record!(
|
||||||
|
tournament: match.tournament,
|
||||||
|
user: user,
|
||||||
|
action: 'match_score.updated',
|
||||||
|
message: match_score_message(
|
||||||
|
match_score: match_score,
|
||||||
|
user: user,
|
||||||
|
changes: changes,
|
||||||
|
group_scores: group_scores
|
||||||
|
),
|
||||||
|
metadata: metadata
|
||||||
|
)
|
||||||
|
end
|
||||||
|
|
||||||
|
def record_decider_match_created!(match:, user:, group_scores: [])
|
||||||
|
record!(
|
||||||
|
tournament: match.tournament,
|
||||||
|
user: user,
|
||||||
|
action: 'decider_match.created',
|
||||||
|
message: "#{user_label(user)} added decider match #{match.id} (#{match_teams_label(match)})",
|
||||||
|
metadata: match_metadata(match).merge(group_scores: group_scores_metadata(group_scores))
|
||||||
|
)
|
||||||
|
end
|
||||||
|
|
||||||
|
def record_playoffs_start_failed!(stage:, user:, reason:, blocking_ties: [])
|
||||||
|
record!(
|
||||||
|
tournament: stage.tournament,
|
||||||
|
user: user,
|
||||||
|
action: 'playoffs.start_failed',
|
||||||
|
message: playoffs_start_failed_message(user: user, reason: reason, blocking_ties: blocking_ties),
|
||||||
|
metadata: {
|
||||||
|
stage_id: stage.id,
|
||||||
|
reason: reason,
|
||||||
|
blocking_ties: blocking_ties
|
||||||
|
}
|
||||||
|
)
|
||||||
|
end
|
||||||
|
|
||||||
|
def record_playoffs_started!(stage:, user:, advancing_teams:)
|
||||||
|
record!(
|
||||||
|
tournament: stage.tournament,
|
||||||
|
user: user,
|
||||||
|
action: 'playoffs.started',
|
||||||
|
message: "#{user_label(user)} started playoffs, advancing teams: #{team_list_label(advancing_teams)}",
|
||||||
|
metadata: {
|
||||||
|
stage_id: stage.id,
|
||||||
|
advancing_team_ids: advancing_teams.map(&:id),
|
||||||
|
advancing_teams: teams_metadata(advancing_teams)
|
||||||
|
}
|
||||||
|
)
|
||||||
|
end
|
||||||
|
|
||||||
|
def record_group_matches_started!(tournament:, user:, matches:)
|
||||||
|
return if matches.empty?
|
||||||
|
|
||||||
|
record!(
|
||||||
|
tournament: tournament,
|
||||||
|
user: user,
|
||||||
|
action: 'group_matches.started',
|
||||||
|
message: "#{user_label(user)} started group matches: #{matches.map(&:id).join(', ')}",
|
||||||
|
metadata: {
|
||||||
|
match_ids: matches.map(&:id),
|
||||||
|
matches: matches.map { |match| match_metadata(match) }
|
||||||
|
}
|
||||||
|
)
|
||||||
|
end
|
||||||
|
|
||||||
|
private
|
||||||
|
|
||||||
|
def next_sequence_for(tournament)
|
||||||
|
TournamentTransactionLogEntry.where(tournament_id: tournament.id).maximum(:sequence).to_i + 1
|
||||||
|
end
|
||||||
|
|
||||||
|
def user_label(user)
|
||||||
|
user&.username.presence || user&.email.presence || 'system'
|
||||||
|
end
|
||||||
|
|
||||||
|
def state_label(state)
|
||||||
|
{
|
||||||
|
'in_progress' => 'running',
|
||||||
|
'not_started' => 'not started',
|
||||||
|
'not_ready' => 'not ready',
|
||||||
|
'single_team' => 'single team',
|
||||||
|
'undecided' => 'undecided',
|
||||||
|
'finished' => 'finished'
|
||||||
|
}.fetch(state.to_s, state.to_s)
|
||||||
|
end
|
||||||
|
|
||||||
|
def match_state_message(match:, user:, new_state:, details:)
|
||||||
|
group_scores = details.fetch(:group_scores, [])
|
||||||
|
advanced_match_ids = details.fetch(:advanced_match_ids, [])
|
||||||
|
stage_type = details.fetch(:stage_type)
|
||||||
|
|
||||||
|
if new_state.to_s == 'finished'
|
||||||
|
return finished_group_match_message(match, user, group_scores) if match.group_match?
|
||||||
|
|
||||||
|
return finished_playoff_match_message(match, user, stage_type, advanced_match_ids)
|
||||||
|
end
|
||||||
|
|
||||||
|
message = "#{user_label(user)} changed match #{match.id} " \
|
||||||
|
"(#{match_teams_label(match)}) to #{state_label(new_state)}"
|
||||||
|
message += ' - scores reset' if new_state.to_s == 'not_started'
|
||||||
|
message
|
||||||
|
end
|
||||||
|
|
||||||
|
def finished_group_match_message(match, user, group_scores)
|
||||||
|
"#{user_label(user)} changed match #{match.id} to finished (#{finish_result_label(match)}) - " \
|
||||||
|
"groupstage - group scores recalculated (#{group_scores_label(group_scores)})"
|
||||||
|
end
|
||||||
|
|
||||||
|
def finished_playoff_match_message(match, user, stage_type, advanced_match_ids)
|
||||||
|
message = "#{user_label(user)} changed match #{match.id} to finished " \
|
||||||
|
"(#{finish_result_label(match)}) - #{stage_type}"
|
||||||
|
return message if advanced_match_ids.empty? || match.winner.nil?
|
||||||
|
|
||||||
|
"#{message} - #{team_label(match.winner)} advances to match #{advanced_match_ids.join(', ')}"
|
||||||
|
end
|
||||||
|
|
||||||
|
def match_score_message(match_score:, user:, changes:, group_scores:)
|
||||||
|
match = match_score.match
|
||||||
|
change_label = changes.map do |field, values|
|
||||||
|
"#{field} from #{values.fetch(:from)} to #{values.fetch(:to)}"
|
||||||
|
end.join(', ')
|
||||||
|
message = "#{user_label(user)} changed matchscore #{match_score.id} from match #{match.id}: #{change_label} " \
|
||||||
|
"(#{score_detail_label(match_score, changes)})"
|
||||||
|
return message unless match.group_match?
|
||||||
|
|
||||||
|
"#{message} - group scores recalculated (#{group_scores_label(group_scores)})"
|
||||||
|
end
|
||||||
|
|
||||||
|
def playoffs_start_failed_message(user:, reason:, blocking_ties:)
|
||||||
|
case reason.to_s
|
||||||
|
when 'decider_missing'
|
||||||
|
"#{user_label(user)} attempted playoff start, failed because decider missing " \
|
||||||
|
"(#{blocking_ties_label(blocking_ties)})"
|
||||||
|
when 'unfinished_matches'
|
||||||
|
"#{user_label(user)} attempted playoff start, failed because group stage has unfinished matches"
|
||||||
|
else
|
||||||
|
"#{user_label(user)} attempted playoff start, failed because #{reason}"
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
def finish_result_label(match)
|
||||||
|
winner = match.winner
|
||||||
|
return "#{team_label(winner)} wins" unless winner.nil?
|
||||||
|
|
||||||
|
teams = match.teams
|
||||||
|
return "draw between #{team_label(teams.first)} and #{team_label(teams.second)}" if teams.size == 2
|
||||||
|
|
||||||
|
'no winner'
|
||||||
|
end
|
||||||
|
|
||||||
|
def score_detail_label(match_score, changes)
|
||||||
|
team = match_score.team
|
||||||
|
opponent = match_score.match.teams.find { |candidate| candidate.id != team.id }
|
||||||
|
field = score_detail_field(changes)
|
||||||
|
points = score_detail_points(match_score, changes, field)
|
||||||
|
"#{points} #{field.to_s.tr('_', ' ')} for #{team_label(team)} in match vs #{team_label(opponent)}"
|
||||||
|
end
|
||||||
|
|
||||||
|
def score_detail_field(changes)
|
||||||
|
return :hidden_points if changes.key?(:hidden_points) || changes.key?('hidden_points')
|
||||||
|
|
||||||
|
:points
|
||||||
|
end
|
||||||
|
|
||||||
|
def score_detail_points(match_score, changes, field)
|
||||||
|
changes.dig(field.to_s, :to) ||
|
||||||
|
changes.dig(field, :to) ||
|
||||||
|
match_score.public_send(field)
|
||||||
|
end
|
||||||
|
|
||||||
|
def match_teams_label(match)
|
||||||
|
teams = match.teams
|
||||||
|
return "between #{team_label(teams.first)} and #{team_label(teams.second)}" if teams.size == 2
|
||||||
|
return "with #{team_label(teams.first)}" if teams.size == 1
|
||||||
|
|
||||||
|
'without assigned teams'
|
||||||
|
end
|
||||||
|
|
||||||
|
def team_label(team)
|
||||||
|
return 'unknown team' if team.nil?
|
||||||
|
|
||||||
|
"#{team.name}(#{team.id})"
|
||||||
|
end
|
||||||
|
|
||||||
|
def team_list_label(teams)
|
||||||
|
teams.map { |team| team_label(team) }.join(', ')
|
||||||
|
end
|
||||||
|
|
||||||
|
def blocking_ties_label(blocking_ties)
|
||||||
|
blocking_ties.map do |tie|
|
||||||
|
names = tie.fetch(:team_names, [])
|
||||||
|
ids = tie.fetch(:team_ids, [])
|
||||||
|
names.zip(ids).map { |name, id| "#{name}(#{id})" }.join(' vs ')
|
||||||
|
end.join(', ')
|
||||||
|
end
|
||||||
|
|
||||||
|
def group_scores_label(group_scores)
|
||||||
|
group_scores_metadata(group_scores).map do |score|
|
||||||
|
"#{score.fetch(:team_name)}(#{score.fetch(:team_id)}) groupscore " \
|
||||||
|
"#{score.fetch(:group_points)}-#{score.fetch(:scored_points)}-#{score.fetch(:received_points)} " \
|
||||||
|
"pos #{score.fetch(:position)}"
|
||||||
|
end.join(', ')
|
||||||
|
end
|
||||||
|
|
||||||
|
def group_scores_metadata(group_scores)
|
||||||
|
Array(group_scores).map do |score|
|
||||||
|
score.reload if score.persisted?
|
||||||
|
{
|
||||||
|
id: score.id,
|
||||||
|
team_id: score.team_id,
|
||||||
|
team_name: score.team.name,
|
||||||
|
group_points: score.group_points,
|
||||||
|
scored_points: score.scored_points,
|
||||||
|
received_points: score.received_points,
|
||||||
|
position: score.position
|
||||||
|
}
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
def match_metadata(match)
|
||||||
|
{
|
||||||
|
match_id: match.id,
|
||||||
|
decider: match.decider?,
|
||||||
|
group_id: match.group_id,
|
||||||
|
stage_id: match.stage_id,
|
||||||
|
team_ids: match.teams.map(&:id),
|
||||||
|
teams: teams_metadata(match.teams)
|
||||||
|
}
|
||||||
|
end
|
||||||
|
|
||||||
|
def teams_metadata(teams)
|
||||||
|
teams.map do |team|
|
||||||
|
{
|
||||||
|
id: team.id,
|
||||||
|
name: team.name
|
||||||
|
}
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
@ -36,6 +36,7 @@ module TurniereBackend
|
||||||
config.api_only = true
|
config.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
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,53 @@
|
||||||
|
# frozen_string_literal: true
|
||||||
|
|
||||||
|
# Devise 4.9 still passes route option hashes to Rails' `resource` helper
|
||||||
|
# positionally. Rails 8.1 accepts that with deprecation warnings and Rails 8.2
|
||||||
|
# removes it, so keep the same Devise routes but pass those options as keywords.
|
||||||
|
module DeviseRoutesRails8Compat
|
||||||
|
def devise_session(mapping, controllers)
|
||||||
|
resource :session, only: [], controller: controllers[:sessions], path: '' do
|
||||||
|
get :new, path: mapping.path_names[:sign_in], as: 'new'
|
||||||
|
post :create, path: mapping.path_names[:sign_in]
|
||||||
|
match :destroy, path: mapping.path_names[:sign_out], as: 'destroy', via: mapping.sign_out_via
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
def devise_password(mapping, controllers)
|
||||||
|
resource :password,
|
||||||
|
only: %i[new create edit update],
|
||||||
|
path: mapping.path_names[:password],
|
||||||
|
controller: controllers[:passwords]
|
||||||
|
end
|
||||||
|
|
||||||
|
def devise_confirmation(mapping, controllers)
|
||||||
|
resource :confirmation,
|
||||||
|
only: %i[new create show],
|
||||||
|
path: mapping.path_names[:confirmation],
|
||||||
|
controller: controllers[:confirmations]
|
||||||
|
end
|
||||||
|
|
||||||
|
def devise_unlock(mapping, controllers)
|
||||||
|
return unless mapping.to.unlock_strategy_enabled?(:email)
|
||||||
|
|
||||||
|
resource :unlock,
|
||||||
|
only: %i[new create show],
|
||||||
|
path: mapping.path_names[:unlock],
|
||||||
|
controller: controllers[:unlocks]
|
||||||
|
end
|
||||||
|
|
||||||
|
def devise_registration(mapping, controllers)
|
||||||
|
resource :registration,
|
||||||
|
only: %i[new create edit update destroy],
|
||||||
|
path: mapping.path_names[:registration],
|
||||||
|
path_names: {
|
||||||
|
new: mapping.path_names[:sign_up],
|
||||||
|
edit: mapping.path_names[:edit],
|
||||||
|
cancel: mapping.path_names[:cancel]
|
||||||
|
},
|
||||||
|
controller: controllers[:registrations] do
|
||||||
|
get :cancel
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
ActionDispatch::Routing::Mapper.prepend(DeviseRoutesRails8Compat)
|
||||||
|
|
@ -48,5 +48,6 @@ DeviseTokenAuth.setup do |config|
|
||||||
# do so by enabling this flag. NOTE: This feature is highly experimental!
|
# 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
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,2 @@
|
||||||
|
---
|
||||||
|
:concurrency: <%= ENV.fetch("SIDEKIQ_CONCURRENCY", 5) %>
|
||||||
|
|
@ -0,0 +1,8 @@
|
||||||
|
# frozen_string_literal: true
|
||||||
|
|
||||||
|
class AddTimerReasonToTournaments < ActiveRecord::Migration[7.0]
|
||||||
|
def change
|
||||||
|
add_column :tournaments, :timer_reason, :string
|
||||||
|
add_column :tournaments, :timer_reason_text, :text
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
@ -0,0 +1,34 @@
|
||||||
|
# frozen_string_literal: true
|
||||||
|
|
||||||
|
require 'openssl'
|
||||||
|
|
||||||
|
class AddSyncAuthTokenDigestToTournaments < ActiveRecord::Migration[7.0]
|
||||||
|
class MigrationTournament < ActiveRecord::Base
|
||||||
|
self.table_name = 'tournaments'
|
||||||
|
end
|
||||||
|
|
||||||
|
def up
|
||||||
|
add_column :tournaments, :sync_auth_token_digest, :string unless column_exists?(:tournaments, :sync_auth_token_digest)
|
||||||
|
|
||||||
|
MigrationTournament.reset_column_information
|
||||||
|
MigrationTournament
|
||||||
|
.where(read_only_mode: true)
|
||||||
|
.where.not(sync_auth_token: [nil, ''])
|
||||||
|
.find_each do |tournament|
|
||||||
|
tournament.update!(
|
||||||
|
sync_auth_token: nil,
|
||||||
|
sync_auth_token_digest: sync_auth_token_digest(tournament.sync_auth_token)
|
||||||
|
)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
def down
|
||||||
|
remove_column :tournaments, :sync_auth_token_digest if column_exists?(:tournaments, :sync_auth_token_digest)
|
||||||
|
end
|
||||||
|
|
||||||
|
private
|
||||||
|
|
||||||
|
def sync_auth_token_digest(token)
|
||||||
|
OpenSSL::HMAC.hexdigest('SHA256', Rails.application.secret_key_base, token.to_s)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
@ -0,0 +1,21 @@
|
||||||
|
# frozen_string_literal: true
|
||||||
|
|
||||||
|
class CreateTournamentTransactionLogEntries < ActiveRecord::Migration[7.0]
|
||||||
|
def change
|
||||||
|
create_table :tournament_transaction_log_entries do |t|
|
||||||
|
t.references :tournament, null: false, foreign_key: { on_delete: :cascade }, index: true
|
||||||
|
t.references :user, foreign_key: { on_delete: :nullify }, index: true
|
||||||
|
t.integer :sequence, null: false
|
||||||
|
t.string :action, null: false
|
||||||
|
t.text :message, null: false
|
||||||
|
t.json :metadata, null: false, default: {}
|
||||||
|
|
||||||
|
t.timestamps
|
||||||
|
end
|
||||||
|
|
||||||
|
add_index :tournament_transaction_log_entries,
|
||||||
|
%i[tournament_id sequence],
|
||||||
|
unique: true,
|
||||||
|
name: 'index_tournament_log_entries_on_tournament_and_sequence'
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
@ -0,0 +1,12 @@
|
||||||
|
# frozen_string_literal: true
|
||||||
|
|
||||||
|
class AddMetadataToTournaments < ActiveRecord::Migration[7.0]
|
||||||
|
def change
|
||||||
|
change_table :tournaments, bulk: true do |t|
|
||||||
|
t.string :location
|
||||||
|
t.datetime :starts_at
|
||||||
|
t.datetime :ends_at
|
||||||
|
t.string :website_url
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
@ -0,0 +1,9 @@
|
||||||
|
# frozen_string_literal: true
|
||||||
|
|
||||||
|
class AddMatchMetadataToMatches < ActiveRecord::Migration[7.0]
|
||||||
|
def change
|
||||||
|
add_column :matches, :location, :string
|
||||||
|
add_column :matches, :start_time, :string
|
||||||
|
add_column :matches, :notes, :text
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
@ -0,0 +1,18 @@
|
||||||
|
# frozen_string_literal: true
|
||||||
|
|
||||||
|
class CreateTeamPlayers < ActiveRecord::Migration[7.0]
|
||||||
|
def change
|
||||||
|
create_table :team_players do |t|
|
||||||
|
t.references :team, null: false, type: :integer, foreign_key: { on_delete: :cascade }, index: true
|
||||||
|
t.references :player,
|
||||||
|
null: false,
|
||||||
|
type: :integer,
|
||||||
|
foreign_key: { to_table: :users, on_delete: :cascade },
|
||||||
|
index: true
|
||||||
|
|
||||||
|
t.timestamps
|
||||||
|
end
|
||||||
|
|
||||||
|
add_index :team_players, %i[team_id player_id], unique: true
|
||||||
|
end
|
||||||
|
end
|
||||||
184
db/schema.rb
184
db/schema.rb
|
|
@ -10,96 +10,99 @@
|
||||||
#
|
#
|
||||||
# It's strongly recommended that you check this file into your version control system.
|
# 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
|
||||||
|
|
|
||||||
10
db/seeds.rb
10
db/seeds.rb
|
|
@ -1,7 +1,13 @@
|
||||||
# frozen_string_literal: true
|
# frozen_string_literal: true
|
||||||
|
|
||||||
|
if Rails.env.production? && ENV.fetch('TURNIERE_ENABLE_DEMO_SEEDS', nil) != '1'
|
||||||
|
warn 'Skipping demo seeds in production. Set TURNIERE_ENABLE_DEMO_SEEDS=1 to force them.'
|
||||||
|
else
|
||||||
|
begin
|
||||||
require 'faker'
|
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
|
user = User.create! username: Faker::Internet.username, email: Faker::Internet.email, password: Faker::Internet.password
|
||||||
tournament = user.tournaments.create! name: Faker::Creature::Dog.name
|
tournament = user.tournaments.create! name: Faker::Creature::Dog.name
|
||||||
|
|
||||||
|
|
@ -24,3 +30,5 @@ end
|
||||||
end
|
end
|
||||||
stage.save!
|
stage.save!
|
||||||
end
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,350 @@
|
||||||
|
# Account Profiles And Team Players API
|
||||||
|
|
||||||
|
This API lets public pages show player account profiles and lets tournament
|
||||||
|
owners associate one or more player accounts with teams.
|
||||||
|
|
||||||
|
Terminology:
|
||||||
|
|
||||||
|
- account: existing `User` record
|
||||||
|
- player account: user account associated with one or more tournament teams
|
||||||
|
- tournament owner: organizer account that owns the tournament
|
||||||
|
- team player: account linked to a team through the `players` array
|
||||||
|
|
||||||
|
There is no separate organizer-only "create player" endpoint. Organizers create
|
||||||
|
or reuse player accounts by sending player email values through the tournament
|
||||||
|
or team endpoints below.
|
||||||
|
|
||||||
|
## Public Account Profile
|
||||||
|
|
||||||
|
Use this for public-facing player profile pages.
|
||||||
|
|
||||||
|
Endpoint:
|
||||||
|
|
||||||
|
- `GET /accounts/:id`
|
||||||
|
|
||||||
|
Authentication:
|
||||||
|
|
||||||
|
- optional
|
||||||
|
- anonymous callers see only public tournament/team profile data
|
||||||
|
- the account owner sees their own private tournament/team profile data too
|
||||||
|
|
||||||
|
Example response:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"id": 42,
|
||||||
|
"name": "alice",
|
||||||
|
"username": "alice",
|
||||||
|
"tournaments": [
|
||||||
|
{
|
||||||
|
"id": 11,
|
||||||
|
"name": "Summer Cup",
|
||||||
|
"code": "a1b2c3",
|
||||||
|
"public": true
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"created_tournaments": [
|
||||||
|
{
|
||||||
|
"id": 12,
|
||||||
|
"name": "Alice Open",
|
||||||
|
"code": "d4e5f6",
|
||||||
|
"public": true
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"teams": [
|
||||||
|
{
|
||||||
|
"id": 99,
|
||||||
|
"name": "Team Alpha",
|
||||||
|
"tournament": {
|
||||||
|
"id": 11,
|
||||||
|
"name": "Summer Cup",
|
||||||
|
"code": "a1b2c3",
|
||||||
|
"public": true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Response fields:
|
||||||
|
|
||||||
|
- `id`: account id
|
||||||
|
- `name`: same value as `username`, for public display
|
||||||
|
- `username`: account username
|
||||||
|
- `tournaments`: tournaments where this account is linked to at least one team
|
||||||
|
- `created_tournaments`: tournaments created by this account
|
||||||
|
- `teams`: teams linked to this account
|
||||||
|
|
||||||
|
Visibility:
|
||||||
|
|
||||||
|
- anonymous/profile visitors see only public tournaments and teams in public tournaments
|
||||||
|
- authenticated profile owner sees public and private tournaments/teams for their own account
|
||||||
|
|
||||||
|
Errors:
|
||||||
|
|
||||||
|
- `404` when account id does not exist
|
||||||
|
|
||||||
|
## Team Read Payload
|
||||||
|
|
||||||
|
Teams expose linked player accounts in a `players` array.
|
||||||
|
|
||||||
|
Endpoint:
|
||||||
|
|
||||||
|
- `GET /teams/:id`
|
||||||
|
|
||||||
|
Example response:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"id": 99,
|
||||||
|
"name": "Team Alpha",
|
||||||
|
"players": [
|
||||||
|
{
|
||||||
|
"id": 42,
|
||||||
|
"name": "alice",
|
||||||
|
"username": "alice"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": 43,
|
||||||
|
"name": "bob",
|
||||||
|
"username": "bob"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Empty team-player association:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"id": 99,
|
||||||
|
"name": "Team Alpha",
|
||||||
|
"players": []
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Tournament Read Payload
|
||||||
|
|
||||||
|
Full tournament reads include the same player summaries on each team.
|
||||||
|
|
||||||
|
Endpoint:
|
||||||
|
|
||||||
|
- `GET /tournaments/:id`
|
||||||
|
|
||||||
|
Relevant response fragment:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"id": 11,
|
||||||
|
"name": "Summer Cup",
|
||||||
|
"teams": [
|
||||||
|
{
|
||||||
|
"id": 99,
|
||||||
|
"name": "Team Alpha",
|
||||||
|
"players": [
|
||||||
|
{
|
||||||
|
"id": 42,
|
||||||
|
"name": "alice",
|
||||||
|
"username": "alice"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"advancing_from_group_stage": false
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
`GET /tournaments/:id?simple=true` does not include teams.
|
||||||
|
|
||||||
|
## Link Players While Creating Teams
|
||||||
|
|
||||||
|
Tournament creation accepts player email values inside new team objects.
|
||||||
|
|
||||||
|
Endpoint:
|
||||||
|
|
||||||
|
- `POST /tournaments`
|
||||||
|
|
||||||
|
Authentication:
|
||||||
|
|
||||||
|
- tournament owner account required
|
||||||
|
|
||||||
|
Single player:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"name": "Summer Cup",
|
||||||
|
"public": true,
|
||||||
|
"teams": [
|
||||||
|
{
|
||||||
|
"name": "Team Alpha",
|
||||||
|
"player_email": "alice@example.com"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "Team Beta"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Multiple players:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"name": "Summer Cup",
|
||||||
|
"public": true,
|
||||||
|
"teams": [
|
||||||
|
{
|
||||||
|
"name": "Team Alpha",
|
||||||
|
"player_emails": [
|
||||||
|
"alice@example.com",
|
||||||
|
"bob@example.com"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Behavior:
|
||||||
|
|
||||||
|
- existing accounts are reused by case-insensitive email match
|
||||||
|
- missing accounts are created by email
|
||||||
|
- no password or token is returned for organizer-created accounts
|
||||||
|
- teams without player email values remain unlinked
|
||||||
|
- existing teams passed by `id` keep their existing player associations
|
||||||
|
|
||||||
|
Group-stage creation uses the same team object fields:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"name": "Summer Cup",
|
||||||
|
"public": true,
|
||||||
|
"group_stage": true,
|
||||||
|
"playoff_teams_amount": 8,
|
||||||
|
"teams": [
|
||||||
|
{
|
||||||
|
"name": "Team Alpha",
|
||||||
|
"group": 0,
|
||||||
|
"player_emails": ["alice@example.com", "bob@example.com"]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Link Or Replace Players On Existing Teams
|
||||||
|
|
||||||
|
Endpoint:
|
||||||
|
|
||||||
|
- `PATCH /teams/:id`
|
||||||
|
- `PUT /teams/:id`
|
||||||
|
|
||||||
|
Authentication:
|
||||||
|
|
||||||
|
- tournament owner may rename the team and manage player links
|
||||||
|
- linked player may rename their own team
|
||||||
|
- linked player may not change player links
|
||||||
|
- other authenticated users receive `403`
|
||||||
|
- read-only follower tournaments receive `423`
|
||||||
|
|
||||||
|
### Rename Team
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"name": "Team Alpha Renamed"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Add One Player By Email
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"player_email": "alice@example.com"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Behavior:
|
||||||
|
|
||||||
|
- existing account is reused
|
||||||
|
- missing account is created
|
||||||
|
- player is appended to the team if not already linked
|
||||||
|
- existing team players stay linked
|
||||||
|
|
||||||
|
### Add One Player By Id
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"player_id": 42
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Behavior:
|
||||||
|
|
||||||
|
- player is appended to the team if not already linked
|
||||||
|
- existing team players stay linked
|
||||||
|
- unknown id returns `404`
|
||||||
|
|
||||||
|
### Replace All Players By Email
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"player_emails": [
|
||||||
|
"alice@example.com",
|
||||||
|
"bob@example.com"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Behavior:
|
||||||
|
|
||||||
|
- replaces the full `players` list for the team
|
||||||
|
- empty array clears all players
|
||||||
|
- blank email values are ignored
|
||||||
|
- missing accounts are created
|
||||||
|
|
||||||
|
### Replace All Players By Id
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"player_ids": [42, 43]
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Behavior:
|
||||||
|
|
||||||
|
- replaces the full `players` list for the team
|
||||||
|
- empty array clears all players
|
||||||
|
- unknown ids return `404`
|
||||||
|
|
||||||
|
### Clear All Players
|
||||||
|
|
||||||
|
Both single-value forms clear all players when sent blank:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"player_email": ""
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"player_id": ""
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Error Handling
|
||||||
|
|
||||||
|
Expected statuses:
|
||||||
|
|
||||||
|
- `200` team/account read or team update accepted
|
||||||
|
- `201` tournament created
|
||||||
|
- `401` authentication required
|
||||||
|
- `403` authenticated user cannot change this team/tournament
|
||||||
|
- `404` account, team, tournament, or player id not found
|
||||||
|
- `422` invalid create/update payload
|
||||||
|
- `423` follower tournament is read-only
|
||||||
|
|
||||||
|
## Sync Behavior
|
||||||
|
|
||||||
|
Team-player account links are not part of leader/follower tournament sync
|
||||||
|
snapshots. Follower tournaments still expose teams, match data, and other synced
|
||||||
|
tournament data, but account ownership links are managed on the writable source
|
||||||
|
side.
|
||||||
|
|
@ -24,6 +24,7 @@ Create empty tournament with:
|
||||||
- `sync_auth_token: <shared secret>`
|
- `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`
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,39 @@
|
||||||
|
# Load Tests
|
||||||
|
|
||||||
|
Load smoke tests live in `e2e/load_test.rb` and run through invoke tasks.
|
||||||
|
|
||||||
|
The runner creates a realistic group-stage tournament over HTTP, then measures:
|
||||||
|
|
||||||
|
- concurrent `GET /tournaments/:id` reads at each configured client level
|
||||||
|
- active ActionCable subscriptions to `TournamentChannel`, opened in bounded
|
||||||
|
batches so the test measures 200 active sockets instead of only handshake
|
||||||
|
burst capacity
|
||||||
|
- `/metrics` websocket gauges and process counters after the run
|
||||||
|
|
||||||
|
It prints relative timings such as p50, p95, max, and requests per second. The
|
||||||
|
CI goal is not a fixed benchmark. The job fails when requests error, websocket
|
||||||
|
clients cannot subscribe, or ActionCable connection metrics do not reflect the
|
||||||
|
opened clients.
|
||||||
|
|
||||||
|
Useful commands:
|
||||||
|
|
||||||
|
- `inv verify-load-test`
|
||||||
|
- `inv load-test --base-url=http://127.0.0.1:3000`
|
||||||
|
- `inv load-test --http-clients=1,10,50,100 --websocket-clients=1,10,50,100,200`
|
||||||
|
- `inv blackbox-load-service`
|
||||||
|
|
||||||
|
Useful environment overrides:
|
||||||
|
|
||||||
|
- `TURNIERE_LOAD_HTTP_CLIENTS=1,10,50,100`
|
||||||
|
- `TURNIERE_LOAD_WEBSOCKET_CLIENTS=1,10,50,100,200`
|
||||||
|
- `TURNIERE_LOAD_HTTP_REQUESTS_PER_CLIENT=3`
|
||||||
|
- `TURNIERE_LOAD_WEBSOCKET_HOLD_SECONDS=1.0`
|
||||||
|
- `TURNIERE_LOAD_WEBSOCKET_CONNECT_BATCH_SIZE=25`
|
||||||
|
- `TURNIERE_LOAD_GROUP_COUNT=4`
|
||||||
|
- `TURNIERE_LOAD_TEAMS_PER_GROUP=4`
|
||||||
|
- `TURNIERE_LOAD_PLAYOFF_TEAMS=8`
|
||||||
|
- `TURNIERE_LOAD_FORMAT=json`
|
||||||
|
|
||||||
|
GitLab CI runs `rails load smoke` against the production image with Postgres,
|
||||||
|
covering read pressure up to 100 HTTP clients and active websocket pressure up
|
||||||
|
to 200 clients.
|
||||||
|
|
@ -0,0 +1,86 @@
|
||||||
|
# Match Metadata API
|
||||||
|
|
||||||
|
Matches can store optional scheduling/display metadata:
|
||||||
|
|
||||||
|
- `location`: string, for venue, court, stream, or table
|
||||||
|
- `start_time`: string, for a local time like `17:00` or an agreed display value
|
||||||
|
- `notes`: free text for one-off match information
|
||||||
|
|
||||||
|
All fields are optional and nullable. Omitted fields are left unchanged.
|
||||||
|
|
||||||
|
## Update One Match
|
||||||
|
|
||||||
|
Use `PATCH /matches/:id` to update metadata on one match. This endpoint still
|
||||||
|
accepts the existing `state` transitions, and metadata can be sent with or
|
||||||
|
without `state`.
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"location": "Main Hall",
|
||||||
|
"start_time": "17:00",
|
||||||
|
"notes": "Opening match. Stream setup needed."
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Owner authentication is required. Read-only follower tournaments reject this
|
||||||
|
with `423 Locked`.
|
||||||
|
|
||||||
|
## Bulk Update Matches
|
||||||
|
|
||||||
|
Use `PATCH /tournaments/:tournament_id/matches/metadata` to update metadata for
|
||||||
|
multiple matches in one tournament.
|
||||||
|
|
||||||
|
At least one metadata field is required:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"start_time": "18:00"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
At least one selector is required:
|
||||||
|
|
||||||
|
- `match_ids`: explicit match ids in this tournament
|
||||||
|
- `group_stage_position`: all group stage matches at that match position
|
||||||
|
- `stage_level`: all playoff matches in that stage level
|
||||||
|
- `stage_id`: all matches in one stage
|
||||||
|
- `position`: all matches with that position
|
||||||
|
- `state`: all matches in that state, including `upcoming`
|
||||||
|
- `all: true`: every match in the tournament
|
||||||
|
|
||||||
|
Explicit ids take precedence over filters. Missing ids outside the tournament
|
||||||
|
return `404 Not Found`.
|
||||||
|
|
||||||
|
Examples:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"match_ids": [1, 2, 5],
|
||||||
|
"start_time": "17:00"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"group_stage_position": 1,
|
||||||
|
"start_time": "18:00"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"stage_level": 3,
|
||||||
|
"location": "Finals Arena",
|
||||||
|
"start_time": "21:00",
|
||||||
|
"notes": "Playoff wave"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Owner authentication is required. Read-only follower tournaments reject this
|
||||||
|
with `423 Locked`.
|
||||||
|
|
||||||
|
## Follower Sync
|
||||||
|
|
||||||
|
`location`, `start_time`, and `notes` are part of tournament sync snapshots.
|
||||||
|
Leader updates enqueue follower sync and live tournament broadcasts. Followers
|
||||||
|
import these fields from the leader and remain read-only until takeover.
|
||||||
|
|
@ -0,0 +1,20 @@
|
||||||
|
# Metrics
|
||||||
|
|
||||||
|
The backend exposes Prometheus text metrics at `GET /metrics`.
|
||||||
|
|
||||||
|
The endpoint is unauthenticated so monitoring systems can scrape it directly. It
|
||||||
|
reports per-process in-memory values:
|
||||||
|
|
||||||
|
- `turniere_http_requests_total`
|
||||||
|
- `turniere_http_request_duration_seconds`
|
||||||
|
- `turniere_action_cable_connections`
|
||||||
|
- `turniere_action_cable_connections_total`
|
||||||
|
- `turniere_process_uptime_seconds`
|
||||||
|
- `turniere_ruby_threads`
|
||||||
|
- `turniere_active_record_connection_pool`
|
||||||
|
- `turniere_build_info`
|
||||||
|
|
||||||
|
This covers app-level request and websocket health. Logs, traces, host metrics,
|
||||||
|
and multi-process aggregation should remain infrastructure concerns. If Puma is
|
||||||
|
ever run with multiple worker processes, scrape each process or container replica
|
||||||
|
and aggregate in the monitoring backend.
|
||||||
|
|
@ -0,0 +1,64 @@
|
||||||
|
# Rails 8 Dependency Update Notes
|
||||||
|
|
||||||
|
## Scope
|
||||||
|
|
||||||
|
This update moves the backend runtime to Ruby 4.0.3, Bundler 4.0.6, Rails 8.1,
|
||||||
|
Puma 8, and the released `devise_token_auth` gem.
|
||||||
|
|
||||||
|
The project no longer uses the Thor77 `devise_token_auth` fork. The released
|
||||||
|
gem currently supports this stack through `devise_token_auth` 1.2.6 with
|
||||||
|
`devise` 4.9.4.
|
||||||
|
|
||||||
|
Because this is an API-only app without session middleware,
|
||||||
|
`DeviseTokenAuth.bypass_sign_in` must stay disabled. With the gem default
|
||||||
|
enabled, authenticated token requests call Devise session bypass code and fail
|
||||||
|
in API-only production. Disabled mode still authenticates token requests with
|
||||||
|
`store: false`.
|
||||||
|
|
||||||
|
## Docker Versioning
|
||||||
|
|
||||||
|
Container versioning still uses the shared pipeline `base_commit` build arg.
|
||||||
|
The production and test images burn that value into `GIT_COMMIT_SHA` at build
|
||||||
|
time.
|
||||||
|
|
||||||
|
Do not set `GIT_COMMIT_SHA` as a runtime service variable in CI or deployment.
|
||||||
|
Runtime overrides can make `/version` report a different SHA than the image was
|
||||||
|
built from.
|
||||||
|
|
||||||
|
## Email Delivery In Blackbox Runs
|
||||||
|
|
||||||
|
`TURNIERE_BLACKBOX_DISABLE_EMAIL_DELIVERY` is a local blackbox compose variable.
|
||||||
|
It maps to the app runtime variable `TURNIERE_DISABLE_EMAIL_DELIVERY`.
|
||||||
|
|
||||||
|
Blackbox production E2E uses real production mode but fake Mailgun credentials.
|
||||||
|
Registration sends a confirmation email, so blackbox runs disable delivery to
|
||||||
|
avoid calling Mailgun while still keeping the production confirmation flow.
|
||||||
|
|
||||||
|
Normal production deployments should not set `TURNIERE_DISABLE_EMAIL_DELIVERY`
|
||||||
|
unless email delivery is intentionally disabled.
|
||||||
|
|
||||||
|
## Schema Diff
|
||||||
|
|
||||||
|
Rails 8.1 dumps columns in a different order than Rails 7. That creates a large
|
||||||
|
`db/schema.rb` diff, but it is ordering churn, not dropped columns.
|
||||||
|
|
||||||
|
Relative to current `master`, no existing schema columns are removed by the
|
||||||
|
dependency update. The timer reason work on `master` adds:
|
||||||
|
|
||||||
|
- `tournaments.timer_reason`
|
||||||
|
- `tournaments.timer_reason_text`
|
||||||
|
|
||||||
|
## API And Frontend Compatibility
|
||||||
|
|
||||||
|
Known frontend-facing API changes are additive:
|
||||||
|
|
||||||
|
- tournament payloads include `timer_reason`
|
||||||
|
- tournament payloads include `timer_reason_text`
|
||||||
|
- timer endpoints accept and return those same fields
|
||||||
|
|
||||||
|
Existing frontend code can ignore these fields and keep using `timestamp` and
|
||||||
|
`timer_mode`. Frontend changes are only needed if the UI should show or edit
|
||||||
|
timer reasons.
|
||||||
|
|
||||||
|
No deployment configuration change is required beyond building/running the new
|
||||||
|
image with the existing shared pipeline build metadata.
|
||||||
|
|
@ -15,6 +15,8 @@ Frontend should treat timer state as:
|
||||||
- no timer: `timestamp = null`, `timer_mode = null`
|
- 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
|
||||||
|
|
|
||||||
|
|
@ -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": [],
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,193 @@
|
||||||
|
# Tournament Management API Additions
|
||||||
|
|
||||||
|
This document covers tournament API additions that are not specific to timers,
|
||||||
|
team action lists, or match metadata.
|
||||||
|
|
||||||
|
Related docs:
|
||||||
|
|
||||||
|
- [timer_api.md](timer_api.md)
|
||||||
|
- [team_action_lists_api.md](team_action_lists_api.md)
|
||||||
|
- [match_metadata_api.md](match_metadata_api.md)
|
||||||
|
- [account_profiles_api.md](account_profiles_api.md)
|
||||||
|
|
||||||
|
## Tournament Metadata Fields
|
||||||
|
|
||||||
|
Tournaments support optional public metadata:
|
||||||
|
|
||||||
|
- `location`: venue, city, stream label, or other display location
|
||||||
|
- `starts_at`: tournament start timestamp
|
||||||
|
- `ends_at`: tournament end timestamp
|
||||||
|
- `website_url`: public URL for the tournament or event
|
||||||
|
|
||||||
|
These fields are included in:
|
||||||
|
|
||||||
|
- `GET /tournaments`
|
||||||
|
- `GET /tournaments/:id?simple=true`
|
||||||
|
- `GET /tournaments/:id`
|
||||||
|
- `POST /tournaments`
|
||||||
|
- `PATCH /tournaments/:id`
|
||||||
|
- leader/follower sync snapshots
|
||||||
|
|
||||||
|
Full and simple tournament responses include:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"id": 11,
|
||||||
|
"name": "Summer Cup",
|
||||||
|
"code": "a1b2c3",
|
||||||
|
"public": true,
|
||||||
|
"location": "Main Hall",
|
||||||
|
"starts_at": "2030-06-01T08:00:00Z",
|
||||||
|
"ends_at": "2030-06-01T16:00:00Z",
|
||||||
|
"website_url": "https://example.com/summer-cup"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Create With Metadata
|
||||||
|
|
||||||
|
Endpoint:
|
||||||
|
|
||||||
|
- `POST /tournaments`
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"name": "Summer Cup",
|
||||||
|
"description": "Annual summer tournament",
|
||||||
|
"public": true,
|
||||||
|
"location": "Main Hall",
|
||||||
|
"starts_at": "2030-06-01T10:00:00+02:00",
|
||||||
|
"ends_at": "2030-06-01T18:00:00+02:00",
|
||||||
|
"website_url": "https://example.com/summer-cup",
|
||||||
|
"teams": [
|
||||||
|
{
|
||||||
|
"name": "Team Alpha"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "Team Beta"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "Team Gamma"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "Team Delta"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Authentication:
|
||||||
|
|
||||||
|
- authenticated user required
|
||||||
|
- created tournament owner is the authenticated user
|
||||||
|
|
||||||
|
### Update Metadata
|
||||||
|
|
||||||
|
Endpoint:
|
||||||
|
|
||||||
|
- `PATCH /tournaments/:id`
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"location": "Updated Arena",
|
||||||
|
"starts_at": "2030-07-01T10:00:00+02:00",
|
||||||
|
"ends_at": "2030-07-01T18:00:00+02:00",
|
||||||
|
"website_url": "https://example.com/updated"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Authentication:
|
||||||
|
|
||||||
|
- tournament owner required
|
||||||
|
- read-only follower tournaments reject normal updates with `423`
|
||||||
|
|
||||||
|
Validation:
|
||||||
|
|
||||||
|
- `website_url` must be blank or an `http`/`https` URL
|
||||||
|
- `ends_at` must be after `starts_at` when both are present
|
||||||
|
- `location` and `website_url` are stripped and stored as `null` when blank
|
||||||
|
- timestamp responses are serialized as ISO 8601 strings
|
||||||
|
|
||||||
|
Expected errors:
|
||||||
|
|
||||||
|
- `401` unauthenticated
|
||||||
|
- `403` authenticated but not owner
|
||||||
|
- `422` invalid metadata values
|
||||||
|
- `423` follower tournament is read-only
|
||||||
|
|
||||||
|
## Delete Generated Group-Stage Playoffs
|
||||||
|
|
||||||
|
Use this endpoint when a tournament owner needs to reopen a finished group stage,
|
||||||
|
change group-stage results, and regenerate playoffs.
|
||||||
|
|
||||||
|
Endpoint:
|
||||||
|
|
||||||
|
- `DELETE /tournaments/:id/playoffs`
|
||||||
|
|
||||||
|
Authentication:
|
||||||
|
|
||||||
|
- tournament owner required
|
||||||
|
- read-only follower tournaments reject with `423`
|
||||||
|
|
||||||
|
Success response:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"id": 11,
|
||||||
|
"stages": [
|
||||||
|
{
|
||||||
|
"id": 21,
|
||||||
|
"level": -1,
|
||||||
|
"state": "in_progress"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Success behavior:
|
||||||
|
|
||||||
|
- deletes all playoff stages for the group-stage tournament
|
||||||
|
- keeps the group stage
|
||||||
|
- changes the group stage back to `in_progress`
|
||||||
|
- enqueues tournament sync when configured
|
||||||
|
- broadcasts the normal tournament live update
|
||||||
|
|
||||||
|
Deletion is allowed only when:
|
||||||
|
|
||||||
|
- tournament has a group stage
|
||||||
|
- generated playoff stages exist
|
||||||
|
- every playoff match is still effectively unplayed
|
||||||
|
- playoff match states are `not_started`, `not_ready`, or `single_team`
|
||||||
|
- all playoff match scores and hidden scores are zero
|
||||||
|
|
||||||
|
Deletion is rejected after playoff play has started or scores have changed.
|
||||||
|
|
||||||
|
Expected statuses:
|
||||||
|
|
||||||
|
- `200` playoffs deleted
|
||||||
|
- `401` unauthenticated
|
||||||
|
- `403` authenticated but not tournament owner
|
||||||
|
- `404` tournament not found
|
||||||
|
- `422` no group stage, no generated playoffs, or playoffs no longer deletable
|
||||||
|
- `423` follower tournament is read-only
|
||||||
|
|
||||||
|
Common `422` errors:
|
||||||
|
|
||||||
|
- `Only group-stage playoffs can be deleted`
|
||||||
|
- `Playoffs have not been generated`
|
||||||
|
- `Playoffs cannot be deleted after matches have started or scores changed`
|
||||||
|
|
||||||
|
## Existing Related Endpoints
|
||||||
|
|
||||||
|
Match scheduling/display metadata lives in [match_metadata_api.md](match_metadata_api.md):
|
||||||
|
|
||||||
|
- `PATCH /matches/:id`
|
||||||
|
- `PATCH /tournaments/:tournament_id/matches/metadata`
|
||||||
|
|
||||||
|
Player account profile and team ownership APIs live in
|
||||||
|
[account_profiles_api.md](account_profiles_api.md):
|
||||||
|
|
||||||
|
- `GET /accounts/:id`
|
||||||
|
- `GET /teams/:id`
|
||||||
|
- `PATCH /teams/:id`
|
||||||
|
- `PUT /teams/:id`
|
||||||
|
- `POST /tournaments` team `player_email` / `player_emails` fields
|
||||||
|
|
@ -0,0 +1,281 @@
|
||||||
|
# Tournament Transaction Log API
|
||||||
|
|
||||||
|
## Purpose
|
||||||
|
|
||||||
|
Tournament transaction log is owner-readable tournament history.
|
||||||
|
|
||||||
|
It records ordered tournament events such as:
|
||||||
|
|
||||||
|
- tournament creation
|
||||||
|
- timer changes
|
||||||
|
- match state changes
|
||||||
|
- match score changes
|
||||||
|
- decider match creation
|
||||||
|
- playoff start attempts and failures
|
||||||
|
- playoff start success
|
||||||
|
- group match auto-starts
|
||||||
|
|
||||||
|
Frontend should show this as an append-only history feed for tournament owners.
|
||||||
|
|
||||||
|
## Read Flow
|
||||||
|
|
||||||
|
Transaction log is not embedded in normal tournament payload.
|
||||||
|
Read it from dedicated owner-only endpoint:
|
||||||
|
|
||||||
|
- `GET /tournaments/:id/transaction_log`
|
||||||
|
|
||||||
|
Auth:
|
||||||
|
|
||||||
|
- same Devise token auth headers as other owner APIs
|
||||||
|
- endpoint requires authenticated tournament owner
|
||||||
|
|
||||||
|
Response:
|
||||||
|
|
||||||
|
- `200 OK`
|
||||||
|
- JSON array
|
||||||
|
- ordered by `sequence` ascending
|
||||||
|
- no pagination yet
|
||||||
|
|
||||||
|
Example:
|
||||||
|
|
||||||
|
```json
|
||||||
|
[
|
||||||
|
{
|
||||||
|
"id": 17,
|
||||||
|
"sequence": 1,
|
||||||
|
"action": "tournament.created",
|
||||||
|
"message": "owner created tournament Spring Cup(123)",
|
||||||
|
"metadata": {
|
||||||
|
"tournament_id": 123,
|
||||||
|
"tournament_name": "Spring Cup"
|
||||||
|
},
|
||||||
|
"created_at": "2026-04-30T10:12:42Z",
|
||||||
|
"user": {
|
||||||
|
"id": 4,
|
||||||
|
"username": "owner"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": 18,
|
||||||
|
"sequence": 2,
|
||||||
|
"action": "match.state_changed",
|
||||||
|
"message": "owner changed match 77 (Team A(10) vs Team B(11)) to running",
|
||||||
|
"metadata": {
|
||||||
|
"match_id": 77,
|
||||||
|
"stage_type": "playoffs",
|
||||||
|
"old_state": "not_started",
|
||||||
|
"new_state": "in_progress",
|
||||||
|
"team_ids": [10, 11],
|
||||||
|
"teams": [
|
||||||
|
{ "id": 10, "name": "Team A" },
|
||||||
|
{ "id": 11, "name": "Team B" }
|
||||||
|
],
|
||||||
|
"winner_team_id": null,
|
||||||
|
"advanced_match_ids": [],
|
||||||
|
"group_scores": []
|
||||||
|
},
|
||||||
|
"created_at": "2026-04-30T10:13:01Z",
|
||||||
|
"user": {
|
||||||
|
"id": 4,
|
||||||
|
"username": "owner"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
|
```
|
||||||
|
|
||||||
|
## Field Contract
|
||||||
|
|
||||||
|
Each entry has:
|
||||||
|
|
||||||
|
- `id`: DB id for entry
|
||||||
|
- `sequence`: per-tournament monotonic order number, starts at `1`
|
||||||
|
- `action`: machine-readable action key
|
||||||
|
- `message`: human-readable owner history text
|
||||||
|
- `metadata`: structured action-specific data
|
||||||
|
- `created_at`: ISO-8601 timestamp
|
||||||
|
- `user`: actor object, or `null` for system actions
|
||||||
|
|
||||||
|
`user` shape:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"id": 4,
|
||||||
|
"username": "owner"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Frontend display guidance:
|
||||||
|
|
||||||
|
- sort by `sequence`, not by timestamp
|
||||||
|
- use `message` for first UI pass
|
||||||
|
- use `metadata` for links/details/chips
|
||||||
|
- tolerate unknown future `action` values
|
||||||
|
- tolerate extra `metadata` fields
|
||||||
|
|
||||||
|
## Current Action Keys
|
||||||
|
|
||||||
|
Current `action` values:
|
||||||
|
|
||||||
|
- `tournament.created`
|
||||||
|
- `tournament.timer_changed`
|
||||||
|
- `match.state_changed`
|
||||||
|
- `match_score.updated`
|
||||||
|
- `decider_match.created`
|
||||||
|
- `playoffs.start_failed`
|
||||||
|
- `playoffs.started`
|
||||||
|
- `group_matches.started`
|
||||||
|
|
||||||
|
Future backend work may add more action keys.
|
||||||
|
Unknown actions should still render via `message`.
|
||||||
|
|
||||||
|
## Metadata Notes
|
||||||
|
|
||||||
|
`metadata` is stable JSON, but shape depends on `action`.
|
||||||
|
|
||||||
|
Common match metadata includes:
|
||||||
|
|
||||||
|
- `match_id`
|
||||||
|
- `stage_id`
|
||||||
|
- `group_id`
|
||||||
|
- `stage_type`: `groupstage` or `playoffs`
|
||||||
|
- `team_ids`
|
||||||
|
- `teams`
|
||||||
|
- `winner_team_id`
|
||||||
|
- `advanced_match_ids`
|
||||||
|
- `group_scores`
|
||||||
|
|
||||||
|
Team object shape:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"id": 10,
|
||||||
|
"name": "Team A"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Group score object shape:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"team_id": 10,
|
||||||
|
"team_name": "Team A",
|
||||||
|
"points": 9,
|
||||||
|
"matches_played": 3,
|
||||||
|
"wins": 3,
|
||||||
|
"draws": 0,
|
||||||
|
"losses": 0,
|
||||||
|
"score": 12,
|
||||||
|
"score_against": 4,
|
||||||
|
"score_difference": 8,
|
||||||
|
"position": 1
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Example Actions
|
||||||
|
|
||||||
|
### Match state change
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"action": "match.state_changed",
|
||||||
|
"message": "owner changed match 77 to finished (Team A(10) wins) - playoffs - Team A(10) advances to match 80",
|
||||||
|
"metadata": {
|
||||||
|
"match_id": 77,
|
||||||
|
"stage_type": "playoffs",
|
||||||
|
"old_state": "in_progress",
|
||||||
|
"new_state": "finished",
|
||||||
|
"winner_team_id": 10,
|
||||||
|
"advanced_match_ids": [80]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Match score update
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"action": "match_score.updated",
|
||||||
|
"message": "owner changed matchscore 91 from match 77 to 3 (3 points for Team A(10) in match vs Team B(11))",
|
||||||
|
"metadata": {
|
||||||
|
"match_id": 77,
|
||||||
|
"match_score_id": 91,
|
||||||
|
"team_id": 10,
|
||||||
|
"changes": {
|
||||||
|
"points": {
|
||||||
|
"from": 2,
|
||||||
|
"to": 3
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Playoff start failure
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"action": "playoffs.start_failed",
|
||||||
|
"message": "owner attempted playoff start, failed because decider missing",
|
||||||
|
"metadata": {
|
||||||
|
"stage_id": 12,
|
||||||
|
"reason": "decider_missing",
|
||||||
|
"blocking_ties": [
|
||||||
|
{
|
||||||
|
"group_id": 5,
|
||||||
|
"team_ids": [10, 11],
|
||||||
|
"teams": [
|
||||||
|
{ "id": 10, "name": "Team A" },
|
||||||
|
{ "id": 11, "name": "Team B" }
|
||||||
|
]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Playoffs started
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"action": "playoffs.started",
|
||||||
|
"message": "owner started playoffs, advancing teams: Team A(10), Team B(11), Team C(12), Team D(13)",
|
||||||
|
"metadata": {
|
||||||
|
"stage_id": 12,
|
||||||
|
"advancing_team_ids": [10, 11, 12, 13],
|
||||||
|
"advancing_teams": [
|
||||||
|
{ "id": 10, "name": "Team A" },
|
||||||
|
{ "id": 11, "name": "Team B" },
|
||||||
|
{ "id": 12, "name": "Team C" },
|
||||||
|
{ "id": 13, "name": "Team D" }
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Error Behavior
|
||||||
|
|
||||||
|
Expected errors:
|
||||||
|
|
||||||
|
- `401 Unauthorized`: missing or invalid auth
|
||||||
|
- `403 Forbidden`: authenticated user is not tournament owner
|
||||||
|
- `404 Not Found`: tournament id does not exist
|
||||||
|
|
||||||
|
Read-only follower tournaments:
|
||||||
|
|
||||||
|
- log read is allowed for owner
|
||||||
|
- log write is internal only; frontend has no write endpoint
|
||||||
|
|
||||||
|
## Frontend Implementation Guide
|
||||||
|
|
||||||
|
Recommended owner-history flow:
|
||||||
|
|
||||||
|
1. Load tournament as usual.
|
||||||
|
2. If current user is owner, request `GET /tournaments/:id/transaction_log`.
|
||||||
|
3. Render entries sorted by `sequence`.
|
||||||
|
4. Use `message` as main text.
|
||||||
|
5. Show `created_at` as event time.
|
||||||
|
6. Link matches/teams from `metadata` when ids are present.
|
||||||
|
7. Refresh after owner mutations, or poll while history panel is open.
|
||||||
|
|
||||||
|
There is no websocket stream for transaction log yet.
|
||||||
|
Existing tournament/match websocket updates still fire for live tournament state.
|
||||||
|
|
@ -37,12 +37,19 @@ services:
|
||||||
source-postgres:
|
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"
|
||||||
|
|
|
||||||
|
|
@ -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:
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,338 @@
|
||||||
|
# frozen_string_literal: true
|
||||||
|
|
||||||
|
require 'json'
|
||||||
|
require 'time'
|
||||||
|
|
||||||
|
require_relative 'action_cable_client'
|
||||||
|
require_relative 'api_client'
|
||||||
|
require_relative 'scenario_runner'
|
||||||
|
|
||||||
|
module TurniereE2E
|
||||||
|
class LoadTestRunner
|
||||||
|
DEFAULT_HTTP_CLIENTS = [1, 10, 50, 100].freeze
|
||||||
|
DEFAULT_WEBSOCKET_CLIENTS = [1, 10, 50, 100, 200].freeze
|
||||||
|
|
||||||
|
def initialize(**options)
|
||||||
|
@base_url = options.fetch(:base_url).delete_suffix('/')
|
||||||
|
@email = options[:email]
|
||||||
|
@password = options[:password]
|
||||||
|
@username = options[:username]
|
||||||
|
@tournament_id = options[:tournament_id]
|
||||||
|
@http_clients = options.fetch(:http_clients, DEFAULT_HTTP_CLIENTS)
|
||||||
|
@websocket_clients = options.fetch(:websocket_clients, DEFAULT_WEBSOCKET_CLIENTS)
|
||||||
|
@http_requests_per_client = options.fetch(:http_requests_per_client, 3).to_i
|
||||||
|
@websocket_hold_seconds = options.fetch(:websocket_hold_seconds, 1.0).to_f
|
||||||
|
@websocket_connect_batch_size = options.fetch(:websocket_connect_batch_size, 25).to_i
|
||||||
|
@group_count = options.fetch(:group_count, 4).to_i
|
||||||
|
@teams_per_group = options.fetch(:teams_per_group, 4).to_i
|
||||||
|
@playoff_teams_amount = options.fetch(:playoff_teams_amount, 8).to_i
|
||||||
|
@fail_on_error = options.fetch(:fail_on_error, true)
|
||||||
|
end
|
||||||
|
|
||||||
|
def run
|
||||||
|
tournament = prepare_tournament
|
||||||
|
result = {
|
||||||
|
base_url: base_url,
|
||||||
|
tournament: tournament,
|
||||||
|
http: http_clients.map { |client_count| run_http_level(tournament, client_count) },
|
||||||
|
websocket: websocket_clients.map { |client_count| run_websocket_level(tournament, client_count) },
|
||||||
|
metrics: metrics_summary
|
||||||
|
}
|
||||||
|
|
||||||
|
assert_success!(result) if fail_on_error
|
||||||
|
result
|
||||||
|
end
|
||||||
|
|
||||||
|
private
|
||||||
|
|
||||||
|
attr_reader :base_url, :email, :password, :username, :tournament_id, :http_clients, :websocket_clients,
|
||||||
|
:http_requests_per_client, :websocket_hold_seconds, :websocket_connect_batch_size, :group_count,
|
||||||
|
:teams_per_group, :playoff_teams_amount, :fail_on_error
|
||||||
|
|
||||||
|
def prepare_tournament
|
||||||
|
return fetch_existing_tournament if tournament_id.to_s.strip != ''
|
||||||
|
|
||||||
|
runner = ScenarioRunner.new(base_url: base_url, email: email, password: password, username: username)
|
||||||
|
runner.wait_for_healthcheck!
|
||||||
|
result = runner.run_group_stage_render_profile(
|
||||||
|
group_count: group_count,
|
||||||
|
teams_per_group: teams_per_group,
|
||||||
|
playoff_teams_amount: playoff_teams_amount,
|
||||||
|
stop_at: :created
|
||||||
|
)
|
||||||
|
result.fetch(:tournament)
|
||||||
|
end
|
||||||
|
|
||||||
|
def fetch_existing_tournament
|
||||||
|
client = ApiClient.new(base_url: base_url)
|
||||||
|
response = client.get("/tournaments/#{tournament_id}")
|
||||||
|
unless response.fetch(:status) == 200
|
||||||
|
raise ApiError.new(
|
||||||
|
"load-test tournament fetch failed with #{response.fetch(:status)}",
|
||||||
|
status: response.fetch(:status),
|
||||||
|
body: response.fetch(:json)
|
||||||
|
)
|
||||||
|
end
|
||||||
|
|
||||||
|
summarize_tournament(response.fetch(:json))
|
||||||
|
end
|
||||||
|
|
||||||
|
def run_http_level(tournament, client_count)
|
||||||
|
path = "/tournaments/#{tournament.fetch(:id)}"
|
||||||
|
records = run_threads(client_count) do
|
||||||
|
client = ApiClient.new(base_url: base_url)
|
||||||
|
Array.new(http_requests_per_client) { measure_http_request(client, path) }
|
||||||
|
end.flatten
|
||||||
|
|
||||||
|
http_summary(client_count, records)
|
||||||
|
end
|
||||||
|
|
||||||
|
def run_websocket_level(tournament, client_count)
|
||||||
|
clients = []
|
||||||
|
baseline_connections = metric_scalar('turniere_action_cable_connections').to_i
|
||||||
|
records = run_websocket_open_batches(tournament.fetch(:id), client_count, clients)
|
||||||
|
successful = records.count { |record| record[:ok] }
|
||||||
|
observed_connections = wait_for_connection_metric(successful)
|
||||||
|
sleep websocket_hold_seconds if websocket_hold_seconds.positive?
|
||||||
|
|
||||||
|
websocket_summary(client_count, records, baseline_connections, successful, observed_connections)
|
||||||
|
ensure
|
||||||
|
clients.each(&:close)
|
||||||
|
end
|
||||||
|
|
||||||
|
def run_websocket_open_batches(tournament_id, client_count, clients)
|
||||||
|
batch_size = websocket_connect_batch_size.positive? ? websocket_connect_batch_size : client_count
|
||||||
|
|
||||||
|
Array.new(client_count) { true }.each_slice(batch_size).flat_map do |batch|
|
||||||
|
run_threads(batch.count) do
|
||||||
|
measure_websocket_open(tournament_id).tap do |record|
|
||||||
|
clients << record.delete(:client) if record[:client]
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
def run_threads(count)
|
||||||
|
return [] if count.to_i <= 0
|
||||||
|
|
||||||
|
ready = Queue.new
|
||||||
|
start = Queue.new
|
||||||
|
results = Queue.new
|
||||||
|
threads = Array.new(count) do
|
||||||
|
Thread.new do
|
||||||
|
ready << true
|
||||||
|
start.pop
|
||||||
|
results << yield
|
||||||
|
rescue StandardError => e
|
||||||
|
results << [{ error: "#{e.class}: #{e.message}" }]
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
count.times { ready.pop }
|
||||||
|
started_at = monotonic_time
|
||||||
|
count.times { start << true }
|
||||||
|
threads.each(&:join)
|
||||||
|
finished_at = monotonic_time
|
||||||
|
|
||||||
|
drain_queue(results).flatten.map do |record|
|
||||||
|
record.is_a?(Hash) ? record.merge(level_duration_seconds: finished_at - started_at) : record
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
def measure_http_request(client, path)
|
||||||
|
started_at = monotonic_time
|
||||||
|
response = client.get(path)
|
||||||
|
{
|
||||||
|
status: response.fetch(:status),
|
||||||
|
duration_ms: elapsed_ms(started_at),
|
||||||
|
body: error_body(response)
|
||||||
|
}.compact
|
||||||
|
rescue StandardError => e
|
||||||
|
{ error: "#{e.class}: #{e.message}", duration_ms: elapsed_ms(started_at) }
|
||||||
|
end
|
||||||
|
|
||||||
|
def measure_websocket_open(tournament_id)
|
||||||
|
client = ActionCableClient.new(base_url: base_url)
|
||||||
|
started_at = monotonic_time
|
||||||
|
client.subscribe_tournament!(tournament_id: tournament_id)
|
||||||
|
payload = client.wait_for_tournament_payload!(timeout: 20)
|
||||||
|
{
|
||||||
|
ok: true,
|
||||||
|
duration_ms: elapsed_ms(started_at),
|
||||||
|
payload_type: payload.fetch('type'),
|
||||||
|
client: client
|
||||||
|
}
|
||||||
|
rescue StandardError => e
|
||||||
|
client&.close
|
||||||
|
{ ok: false, duration_ms: elapsed_ms(started_at), error: "#{e.class}: #{e.message}" }
|
||||||
|
end
|
||||||
|
|
||||||
|
def wait_for_connection_metric(expected, timeout: 10)
|
||||||
|
deadline = monotonic_time + timeout
|
||||||
|
loop do
|
||||||
|
value = metric_scalar('turniere_action_cable_connections')
|
||||||
|
return value if value && value >= expected
|
||||||
|
return value if monotonic_time >= deadline
|
||||||
|
|
||||||
|
sleep 0.2
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
def metrics_summary
|
||||||
|
{
|
||||||
|
action_cable_connections: metric_scalar('turniere_action_cable_connections'),
|
||||||
|
action_cable_connections_total: metric_scalar('turniere_action_cable_connections_total'),
|
||||||
|
ruby_threads: metric_scalar('turniere_ruby_threads'),
|
||||||
|
active_record_connections: metric_samples('turniere_active_record_connection_pool')
|
||||||
|
}
|
||||||
|
end
|
||||||
|
|
||||||
|
def metric_scalar(name)
|
||||||
|
metric_samples(name).map { |sample| sample.fetch(:value) }.max
|
||||||
|
end
|
||||||
|
|
||||||
|
def metric_samples(name)
|
||||||
|
body = metrics_body
|
||||||
|
body.lines.filter_map do |line|
|
||||||
|
next if line.start_with?('#')
|
||||||
|
|
||||||
|
sample, value = line.split(/\s+/, 2)
|
||||||
|
next unless sample == name || sample.start_with?("#{name}{")
|
||||||
|
|
||||||
|
{ sample: sample, value: value.to_f }
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
def metrics_body
|
||||||
|
response = ApiClient.new(base_url: base_url).get('/metrics')
|
||||||
|
return '' unless response.fetch(:status) == 200
|
||||||
|
|
||||||
|
response.dig(:json, :raw_body).to_s
|
||||||
|
end
|
||||||
|
|
||||||
|
def assert_success!(result)
|
||||||
|
failures = []
|
||||||
|
result.fetch(:http).each { |level| failures.concat(http_failures(level)) }
|
||||||
|
result.fetch(:websocket).each { |level| failures.concat(websocket_failures(level)) }
|
||||||
|
|
||||||
|
raise "load test failed: #{failures.join('; ')}" unless failures.empty?
|
||||||
|
end
|
||||||
|
|
||||||
|
def http_summary(client_count, records)
|
||||||
|
durations = records.filter_map { |record| record[:duration_ms] }
|
||||||
|
errors = failed_http_records(records)
|
||||||
|
|
||||||
|
{
|
||||||
|
clients: client_count,
|
||||||
|
requests: records.count,
|
||||||
|
ok: records.count - errors.count,
|
||||||
|
errors: errors.count,
|
||||||
|
statuses: tally(records.filter_map { |record| record[:status] }),
|
||||||
|
duration_ms: durations.sum.round(1),
|
||||||
|
p50_ms: percentile(durations, 0.50),
|
||||||
|
p95_ms: percentile(durations, 0.95),
|
||||||
|
max_ms: durations.max&.round(1),
|
||||||
|
requests_per_second: requests_per_second(records.count, records),
|
||||||
|
error_samples: errors.first(3).map { |record| record.slice(:status, :error, :body) }
|
||||||
|
}
|
||||||
|
end
|
||||||
|
|
||||||
|
def websocket_summary(client_count, records, baseline_connections, successful, observed_connections)
|
||||||
|
durations = records.filter_map { |record| record[:duration_ms] }
|
||||||
|
errors = records.reject { |record| record[:ok] }
|
||||||
|
|
||||||
|
{
|
||||||
|
clients: client_count,
|
||||||
|
opened: successful,
|
||||||
|
errors: errors.count,
|
||||||
|
connection_metric: observed_connections,
|
||||||
|
expected_connection_metric: successful,
|
||||||
|
baseline_connection_metric: baseline_connections,
|
||||||
|
p50_open_ms: percentile(durations, 0.50),
|
||||||
|
p95_open_ms: percentile(durations, 0.95),
|
||||||
|
max_open_ms: durations.max&.round(1),
|
||||||
|
error_samples: errors.first(3).map { |record| record.slice(:error) }
|
||||||
|
}
|
||||||
|
end
|
||||||
|
|
||||||
|
def failed_http_records(records)
|
||||||
|
records.select { |record| record[:error] || record[:status].to_i >= 400 }
|
||||||
|
end
|
||||||
|
|
||||||
|
def http_failures(level)
|
||||||
|
return [] unless level.fetch(:errors).positive?
|
||||||
|
|
||||||
|
["HTTP #{level.fetch(:clients)} clients: #{level.fetch(:errors)} failed"]
|
||||||
|
end
|
||||||
|
|
||||||
|
def websocket_failures(level)
|
||||||
|
failures = []
|
||||||
|
failures << "WS #{level.fetch(:clients)} clients: #{level.fetch(:errors)} failed" if level.fetch(:errors).positive?
|
||||||
|
failures << "WS #{level.fetch(:clients)} clients: only #{level.fetch(:opened)} opened" if websocket_open_short?(level)
|
||||||
|
if websocket_metric_short?(level)
|
||||||
|
failures << "WS #{level.fetch(:clients)} clients: metrics saw #{level.fetch(:connection_metric)} connections"
|
||||||
|
end
|
||||||
|
failures
|
||||||
|
end
|
||||||
|
|
||||||
|
def websocket_open_short?(level)
|
||||||
|
level.fetch(:opened) < level.fetch(:clients)
|
||||||
|
end
|
||||||
|
|
||||||
|
def websocket_metric_short?(level)
|
||||||
|
level.fetch(:opened).positive? &&
|
||||||
|
level.fetch(:connection_metric).to_i < level.fetch(:expected_connection_metric).to_i
|
||||||
|
end
|
||||||
|
|
||||||
|
def summarize_tournament(tournament)
|
||||||
|
{
|
||||||
|
id: tournament.fetch(:id),
|
||||||
|
code: tournament.fetch(:code),
|
||||||
|
playoff_teams_amount: tournament.fetch(:playoff_teams_amount),
|
||||||
|
stage_count: tournament.fetch(:stages).count,
|
||||||
|
team_count: tournament.fetch(:teams).count
|
||||||
|
}
|
||||||
|
end
|
||||||
|
|
||||||
|
def error_body(response)
|
||||||
|
return nil if response.fetch(:status).to_i < 400
|
||||||
|
|
||||||
|
response.fetch(:json)
|
||||||
|
end
|
||||||
|
|
||||||
|
def requests_per_second(request_count, records)
|
||||||
|
level_duration_seconds = records.filter_map { |record| record[:level_duration_seconds] }.max.to_f
|
||||||
|
return nil unless level_duration_seconds.positive?
|
||||||
|
|
||||||
|
(request_count / level_duration_seconds).round(2)
|
||||||
|
end
|
||||||
|
|
||||||
|
def percentile(values, quantile)
|
||||||
|
sorted = values.compact.sort
|
||||||
|
return nil if sorted.empty?
|
||||||
|
|
||||||
|
index = [(sorted.length * quantile).ceil - 1, 0].max
|
||||||
|
sorted.fetch(index).round(1)
|
||||||
|
end
|
||||||
|
|
||||||
|
def tally(values)
|
||||||
|
values.each_with_object(Hash.new(0)) { |value, counts| counts[value.to_s] += 1 }
|
||||||
|
end
|
||||||
|
|
||||||
|
def drain_queue(queue)
|
||||||
|
items = []
|
||||||
|
items << queue.pop(true) until queue.empty?
|
||||||
|
items
|
||||||
|
rescue ThreadError
|
||||||
|
items
|
||||||
|
end
|
||||||
|
|
||||||
|
def monotonic_time
|
||||||
|
Process.clock_gettime(Process::CLOCK_MONOTONIC)
|
||||||
|
end
|
||||||
|
|
||||||
|
def elapsed_ms(started_at)
|
||||||
|
((monotonic_time - started_at) * 1000.0).round(1)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
@ -12,7 +12,7 @@ module TurniereE2E
|
||||||
attr_reader :client
|
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
|
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,104 @@
|
||||||
|
#!/usr/bin/env ruby
|
||||||
|
# frozen_string_literal: true
|
||||||
|
|
||||||
|
require 'json'
|
||||||
|
require 'optparse'
|
||||||
|
|
||||||
|
require_relative 'lib/load_test_runner'
|
||||||
|
|
||||||
|
def parse_client_list(value)
|
||||||
|
return [] if value.nil? || value.strip == '' || value.strip.casecmp('none').zero?
|
||||||
|
|
||||||
|
value.split(',').map { |entry| Integer(entry.strip) }
|
||||||
|
end
|
||||||
|
|
||||||
|
options = {
|
||||||
|
base_url: ENV.fetch('TURNIERE_E2E_BASE_URL', 'http://127.0.0.1:3000'),
|
||||||
|
email: ENV['TURNIERE_E2E_EMAIL'],
|
||||||
|
password: ENV['TURNIERE_E2E_PASSWORD'],
|
||||||
|
username: ENV['TURNIERE_E2E_USERNAME'],
|
||||||
|
tournament_id: ENV['TURNIERE_LOAD_TOURNAMENT_ID'],
|
||||||
|
http_clients: parse_client_list(ENV.fetch('TURNIERE_LOAD_HTTP_CLIENTS', '1,10,50,100')),
|
||||||
|
websocket_clients: parse_client_list(ENV.fetch('TURNIERE_LOAD_WEBSOCKET_CLIENTS', '1,10,50,100,200')),
|
||||||
|
http_requests_per_client: Integer(ENV.fetch('TURNIERE_LOAD_HTTP_REQUESTS_PER_CLIENT', '3')),
|
||||||
|
websocket_hold_seconds: Float(ENV.fetch('TURNIERE_LOAD_WEBSOCKET_HOLD_SECONDS', '1.0')),
|
||||||
|
websocket_connect_batch_size: Integer(ENV.fetch('TURNIERE_LOAD_WEBSOCKET_CONNECT_BATCH_SIZE', '25')),
|
||||||
|
group_count: Integer(ENV.fetch('TURNIERE_LOAD_GROUP_COUNT', '4')),
|
||||||
|
teams_per_group: Integer(ENV.fetch('TURNIERE_LOAD_TEAMS_PER_GROUP', '4')),
|
||||||
|
playoff_teams_amount: Integer(ENV.fetch('TURNIERE_LOAD_PLAYOFF_TEAMS', '8')),
|
||||||
|
format: ENV.fetch('TURNIERE_LOAD_FORMAT', 'text')
|
||||||
|
}
|
||||||
|
|
||||||
|
# rubocop:disable Metrics/BlockLength
|
||||||
|
OptionParser.new do |parser|
|
||||||
|
parser.banner = 'Usage: ruby e2e/load_test.rb [options]'
|
||||||
|
|
||||||
|
parser.on('--base-url URL', 'API base URL') { |value| options[:base_url] = value }
|
||||||
|
parser.on('--email EMAIL', 'Confirmed E2E user email') { |value| options[:email] = value }
|
||||||
|
parser.on('--password PASSWORD', 'Confirmed E2E user password') { |value| options[:password] = value }
|
||||||
|
parser.on('--username USERNAME', 'Confirmed E2E user username') { |value| options[:username] = value }
|
||||||
|
parser.on('--tournament-id ID', 'Existing tournament ID instead of creating one') do |value|
|
||||||
|
options[:tournament_id] = value
|
||||||
|
end
|
||||||
|
parser.on('--http-clients LIST', 'Comma-separated HTTP client levels, or none') do |value|
|
||||||
|
options[:http_clients] = parse_client_list(value)
|
||||||
|
end
|
||||||
|
parser.on('--websocket-clients LIST', 'Comma-separated websocket client levels, or none') do |value|
|
||||||
|
options[:websocket_clients] = parse_client_list(value)
|
||||||
|
end
|
||||||
|
parser.on('--http-requests-per-client COUNT', Integer, 'GET requests per HTTP client') do |value|
|
||||||
|
options[:http_requests_per_client] = value
|
||||||
|
end
|
||||||
|
parser.on('--websocket-hold-seconds SECONDS', Float, 'Seconds to hold websocket clients open') do |value|
|
||||||
|
options[:websocket_hold_seconds] = value
|
||||||
|
end
|
||||||
|
parser.on('--websocket-connect-batch-size COUNT', Integer, 'Concurrent websocket handshakes per batch') do |value|
|
||||||
|
options[:websocket_connect_batch_size] = value
|
||||||
|
end
|
||||||
|
parser.on('--group-count COUNT', Integer, 'Groups in generated tournament') { |value| options[:group_count] = value }
|
||||||
|
parser.on('--teams-per-group COUNT', Integer, 'Teams per generated group') do |value|
|
||||||
|
options[:teams_per_group] = value
|
||||||
|
end
|
||||||
|
parser.on('--playoff-teams-amount COUNT', Integer, 'Playoff teams in generated tournament') do |value|
|
||||||
|
options[:playoff_teams_amount] = value
|
||||||
|
end
|
||||||
|
parser.on('--format FORMAT', 'text or json') { |value| options[:format] = value }
|
||||||
|
end.parse!
|
||||||
|
# rubocop:enable Metrics/BlockLength
|
||||||
|
|
||||||
|
runner_options = options.dup
|
||||||
|
runner_options.delete(:format)
|
||||||
|
runner = TurniereE2E::LoadTestRunner.new(**runner_options)
|
||||||
|
result = runner.run
|
||||||
|
|
||||||
|
if options[:format] == 'json'
|
||||||
|
puts JSON.pretty_generate(result)
|
||||||
|
exit
|
||||||
|
end
|
||||||
|
|
||||||
|
puts "Load test base_url=#{result.fetch(:base_url)} tournament_id=#{result.dig(:tournament, :id)}"
|
||||||
|
|
||||||
|
result.fetch(:http).each do |level|
|
||||||
|
puts(
|
||||||
|
"HTTP #{level.fetch(:clients)} clients: " \
|
||||||
|
"#{level.fetch(:ok)}/#{level.fetch(:requests)} ok, errors=#{level.fetch(:errors)}, " \
|
||||||
|
"p50=#{level.fetch(:p50_ms)}ms, p95=#{level.fetch(:p95_ms)}ms, " \
|
||||||
|
"max=#{level.fetch(:max_ms)}ms, rps=#{level.fetch(:requests_per_second)}"
|
||||||
|
)
|
||||||
|
end
|
||||||
|
|
||||||
|
result.fetch(:websocket).each do |level|
|
||||||
|
puts(
|
||||||
|
"WS #{level.fetch(:clients)} clients: " \
|
||||||
|
"opened=#{level.fetch(:opened)}, errors=#{level.fetch(:errors)}, " \
|
||||||
|
"p50_open=#{level.fetch(:p50_open_ms)}ms, p95_open=#{level.fetch(:p95_open_ms)}ms, " \
|
||||||
|
"connections_metric=#{level.fetch(:connection_metric)}"
|
||||||
|
)
|
||||||
|
end
|
||||||
|
|
||||||
|
metrics = result.fetch(:metrics)
|
||||||
|
puts(
|
||||||
|
"Metrics: action_cable_connections=#{metrics.fetch(:action_cable_connections)}, " \
|
||||||
|
"action_cable_connections_total=#{metrics.fetch(:action_cable_connections_total)}, " \
|
||||||
|
"ruby_threads=#{metrics.fetch(:ruby_threads)}"
|
||||||
|
)
|
||||||
|
|
@ -52,6 +52,8 @@ result = case scenario
|
||||||
runner.run_group_stage_main_usecase(stop_at: options[:stop_at] || :final_finish_attempted)
|
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'
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,148 @@
|
||||||
|
# frozen_string_literal: true
|
||||||
|
|
||||||
|
require 'erb'
|
||||||
|
require 'yaml'
|
||||||
|
|
||||||
|
RSpec.describe 'runtime scaling configuration' do
|
||||||
|
around do |example|
|
||||||
|
with_env(
|
||||||
|
'DATABASE_URL' => nil,
|
||||||
|
'POSTGRES_URL' => nil,
|
||||||
|
'PORT' => nil,
|
||||||
|
'RAILS_DB_POOL' => nil,
|
||||||
|
'RAILS_ENV' => nil,
|
||||||
|
'RAILS_MAX_THREADS' => nil,
|
||||||
|
'RAILS_MIN_THREADS' => nil,
|
||||||
|
'SIDEKIQ_CONCURRENCY' => nil,
|
||||||
|
'WEB_CONCURRENCY' => nil
|
||||||
|
) { example.run }
|
||||||
|
end
|
||||||
|
|
||||||
|
it 'configures Puma workers and threads from environment variables' do
|
||||||
|
with_env(
|
||||||
|
'PORT' => '4001',
|
||||||
|
'RAILS_ENV' => 'production',
|
||||||
|
'RAILS_MAX_THREADS' => '7',
|
||||||
|
'RAILS_MIN_THREADS' => '2',
|
||||||
|
'WEB_CONCURRENCY' => '4'
|
||||||
|
) do
|
||||||
|
config = evaluate_puma_config
|
||||||
|
|
||||||
|
expect(config.fetch(:threads)).to eq(%w[2 7])
|
||||||
|
expect(config.fetch(:workers)).to eq('4')
|
||||||
|
expect(config.fetch(:preload_app)).to be(true)
|
||||||
|
expect(config.fetch(:port)).to eq('4001')
|
||||||
|
expect(config.fetch(:environment)).to eq('production')
|
||||||
|
expect(config.fetch(:plugin)).to eq(:tmp_restart)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
it 'uses safe Puma defaults' do
|
||||||
|
config = evaluate_puma_config
|
||||||
|
|
||||||
|
expect(config.fetch(:threads)).to eq([5, 5])
|
||||||
|
expect(config.fetch(:workers)).to eq(2)
|
||||||
|
expect(config.fetch(:port)).to eq(3000)
|
||||||
|
expect(config.fetch(:environment)).to eq('production')
|
||||||
|
end
|
||||||
|
|
||||||
|
it 'configures the production database pool from RAILS_DB_POOL' do
|
||||||
|
with_env(
|
||||||
|
'DATABASE_URL' => 'postgres://turniere:secret@postgres/turniere',
|
||||||
|
'RAILS_DB_POOL' => '9',
|
||||||
|
'RAILS_MAX_THREADS' => '7'
|
||||||
|
) do
|
||||||
|
config = database_config.fetch('production')
|
||||||
|
|
||||||
|
expect(config.fetch('adapter')).to eq('postgresql')
|
||||||
|
expect(config.fetch('encoding')).to eq('unicode')
|
||||||
|
expect(config.fetch('url')).to eq('postgres://turniere:secret@postgres/turniere')
|
||||||
|
expect(config.fetch('pool')).to eq(9)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
it 'falls back to RAILS_MAX_THREADS for the database pool and POSTGRES_URL for legacy deployments' do
|
||||||
|
with_env(
|
||||||
|
'POSTGRES_URL' => 'postgres://turniere:secret@legacy-postgres/turniere',
|
||||||
|
'RAILS_MAX_THREADS' => '6'
|
||||||
|
) do
|
||||||
|
config = database_config.fetch('production')
|
||||||
|
|
||||||
|
expect(config.fetch('url')).to eq('postgres://turniere:secret@legacy-postgres/turniere')
|
||||||
|
expect(config.fetch('pool')).to eq(6)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
it 'configures Sidekiq concurrency from SIDEKIQ_CONCURRENCY' do
|
||||||
|
with_env('SIDEKIQ_CONCURRENCY' => '10') do
|
||||||
|
expect(sidekiq_config).to include(':concurrency: 10')
|
||||||
|
end
|
||||||
|
|
||||||
|
expect(sidekiq_config).to include(':concurrency: 5')
|
||||||
|
end
|
||||||
|
|
||||||
|
def evaluate_puma_config
|
||||||
|
probe = puma_config_probe_class.new
|
||||||
|
probe.instance_eval(File.read(File.join(root, 'config/puma.rb')), File.join(root, 'config/puma.rb'))
|
||||||
|
probe.config
|
||||||
|
end
|
||||||
|
|
||||||
|
def database_config
|
||||||
|
YAML.safe_load(ERB.new(File.read(File.join(root, 'config/database.yml'))).result, aliases: true)
|
||||||
|
end
|
||||||
|
|
||||||
|
def sidekiq_config
|
||||||
|
ERB.new(File.read(File.join(root, 'config/sidekiq.yml'))).result
|
||||||
|
end
|
||||||
|
|
||||||
|
def with_env(values)
|
||||||
|
previous = values.to_h { |key, _value| [key, ENV.fetch(key, nil)] }
|
||||||
|
values.each do |key, value|
|
||||||
|
value.nil? ? ENV.delete(key) : ENV[key] = value
|
||||||
|
end
|
||||||
|
|
||||||
|
yield
|
||||||
|
ensure
|
||||||
|
previous.each do |key, value|
|
||||||
|
value.nil? ? ENV.delete(key) : ENV[key] = value
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
def root
|
||||||
|
File.expand_path('../..', __dir__)
|
||||||
|
end
|
||||||
|
|
||||||
|
def puma_config_probe_class
|
||||||
|
Class.new do
|
||||||
|
attr_reader :config
|
||||||
|
|
||||||
|
def initialize
|
||||||
|
@config = {}
|
||||||
|
end
|
||||||
|
|
||||||
|
def threads(min_threads, max_threads)
|
||||||
|
@config[:threads] = [min_threads, max_threads]
|
||||||
|
end
|
||||||
|
|
||||||
|
def workers(count)
|
||||||
|
@config[:workers] = count
|
||||||
|
end
|
||||||
|
|
||||||
|
def preload_app!
|
||||||
|
@config[:preload_app] = true
|
||||||
|
end
|
||||||
|
|
||||||
|
def port(value)
|
||||||
|
@config[:port] = value
|
||||||
|
end
|
||||||
|
|
||||||
|
def environment(value)
|
||||||
|
@config[:environment] = value
|
||||||
|
end
|
||||||
|
|
||||||
|
def plugin(name)
|
||||||
|
@config[:plugin] = name
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
@ -0,0 +1,54 @@
|
||||||
|
# frozen_string_literal: true
|
||||||
|
|
||||||
|
require 'rails_helper'
|
||||||
|
|
||||||
|
RSpec.describe AccountsController, type: :controller do
|
||||||
|
describe 'GET #show' do
|
||||||
|
let(:account) { create(:user, username: 'player-one') }
|
||||||
|
let(:public_team) { create(:team, name: 'Public Team', players: [account]) }
|
||||||
|
let(:private_team) { create(:team, name: 'Private Team', players: [account]) }
|
||||||
|
let(:public_tournament) { create(:tournament, public: true, teams: [public_team]) }
|
||||||
|
let(:private_tournament) { create(:tournament, public: false, teams: [private_team]) }
|
||||||
|
let(:created_public_tournament) { create(:tournament, user: account, public: true) }
|
||||||
|
let(:created_private_tournament) { create(:tournament, user: account, public: false) }
|
||||||
|
|
||||||
|
before do
|
||||||
|
public_tournament
|
||||||
|
private_tournament
|
||||||
|
created_public_tournament
|
||||||
|
created_private_tournament
|
||||||
|
end
|
||||||
|
|
||||||
|
it 'returns public account profile information' do
|
||||||
|
get :show, params: { id: account.to_param }
|
||||||
|
|
||||||
|
body = deserialize_response(response)
|
||||||
|
expect(response).to be_successful
|
||||||
|
expect(body[:id]).to eq(account.id)
|
||||||
|
expect(body[:name]).to eq('player-one')
|
||||||
|
expect(body[:username]).to eq('player-one')
|
||||||
|
expect(body[:teams].map { |team| team[:id] }).to eq([public_team.id])
|
||||||
|
expect(body[:tournaments].map { |tournament| tournament[:id] }).to eq([public_tournament.id])
|
||||||
|
expect(body[:created_tournaments].map { |tournament| tournament[:id] }).to eq([created_public_tournament.id])
|
||||||
|
end
|
||||||
|
|
||||||
|
it 'returns private profile information to the account owner' do
|
||||||
|
apply_authentication_headers_for account
|
||||||
|
|
||||||
|
get :show, params: { id: account.to_param }
|
||||||
|
|
||||||
|
body = deserialize_response(response)
|
||||||
|
expect(body[:teams].map { |team| team[:id] }).to match_array([public_team.id, private_team.id])
|
||||||
|
expect(body[:tournaments].map { |tournament| tournament[:id] })
|
||||||
|
.to match_array([public_tournament.id, private_tournament.id])
|
||||||
|
expect(body[:created_tournaments].map { |tournament| tournament[:id] })
|
||||||
|
.to match_array([created_public_tournament.id, created_private_tournament.id])
|
||||||
|
end
|
||||||
|
|
||||||
|
it 'returns not found for unknown accounts' do
|
||||||
|
get :show, params: { id: User.maximum(:id).to_i + 1 }
|
||||||
|
|
||||||
|
expect(response).to have_http_status(:not_found)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
@ -77,9 +77,9 @@ RSpec.describe BeamersController, type: :controller do
|
||||||
before(:each) { apply_authentication_headers_for @owner }
|
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
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,50 @@
|
||||||
|
# frozen_string_literal: true
|
||||||
|
|
||||||
|
require 'rails_helper'
|
||||||
|
|
||||||
|
RSpec.describe TournamentTransactionLogEntriesController, type: :controller do
|
||||||
|
let(:tournament) { create(:tournament) }
|
||||||
|
let(:owner) { tournament.owner }
|
||||||
|
|
||||||
|
before do
|
||||||
|
TournamentTransactionLogger.record!(
|
||||||
|
tournament: tournament,
|
||||||
|
user: owner,
|
||||||
|
action: 'test.entry',
|
||||||
|
message: 'owner changed match 1',
|
||||||
|
metadata: { match_id: 1 }
|
||||||
|
)
|
||||||
|
end
|
||||||
|
|
||||||
|
describe 'GET #index' do
|
||||||
|
context 'as owner' do
|
||||||
|
before do
|
||||||
|
apply_authentication_headers_for owner
|
||||||
|
end
|
||||||
|
|
||||||
|
it 'returns ordered transaction log entries' do
|
||||||
|
get :index, params: { id: tournament.to_param }
|
||||||
|
|
||||||
|
expect(response).to be_successful
|
||||||
|
body = deserialize_response(response)
|
||||||
|
expect(body.size).to eq(1)
|
||||||
|
expect(body.first[:sequence]).to eq(1)
|
||||||
|
expect(body.first[:action]).to eq('test.entry')
|
||||||
|
expect(body.first[:message]).to eq('owner changed match 1')
|
||||||
|
expect(body.first[:metadata]).to eq(match_id: 1)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
context 'as another user' do
|
||||||
|
before do
|
||||||
|
apply_authentication_headers_for create(:user)
|
||||||
|
end
|
||||||
|
|
||||||
|
it 'returns forbidden' do
|
||||||
|
get :index, params: { id: tournament.to_param }
|
||||||
|
|
||||||
|
expect(response).to have_http_status(:forbidden)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
@ -24,7 +24,7 @@ RSpec.describe TournamentsController, type: :controller do
|
||||||
get :index
|
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
|
||||||
|
|
|
||||||
|
|
@ -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 }
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,36 @@
|
||||||
|
# frozen_string_literal: true
|
||||||
|
|
||||||
|
require 'spec_helper'
|
||||||
|
require_relative '../../../e2e/lib/load_test_runner'
|
||||||
|
|
||||||
|
RSpec.describe 'Load test HTTP E2E' do
|
||||||
|
required_env_vars = %w[TURNIERE_E2E_EMAIL TURNIERE_E2E_PASSWORD].freeze
|
||||||
|
|
||||||
|
before do
|
||||||
|
missing_vars = required_env_vars.reject { |name| ENV.key?(name) }
|
||||||
|
skip("HTTP E2E requires #{missing_vars.join(', ')}") unless missing_vars.empty?
|
||||||
|
end
|
||||||
|
|
||||||
|
let(:base_url) { ENV.fetch('TURNIERE_E2E_BASE_URL', 'http://127.0.0.1:3000') }
|
||||||
|
|
||||||
|
it 'exercises concurrent tournament reads and websocket subscriptions' do
|
||||||
|
result = TurniereE2E::LoadTestRunner.new(
|
||||||
|
base_url: base_url,
|
||||||
|
email: ENV.fetch('TURNIERE_E2E_EMAIL'),
|
||||||
|
password: ENV.fetch('TURNIERE_E2E_PASSWORD'),
|
||||||
|
username: ENV['TURNIERE_E2E_USERNAME'],
|
||||||
|
http_clients: [1, 2],
|
||||||
|
websocket_clients: [1, 2],
|
||||||
|
http_requests_per_client: 1,
|
||||||
|
websocket_hold_seconds: 0.1,
|
||||||
|
group_count: 1,
|
||||||
|
teams_per_group: 4,
|
||||||
|
playoff_teams_amount: 2
|
||||||
|
).run
|
||||||
|
|
||||||
|
expect(result.fetch(:http).map { |level| level.fetch(:errors) }).to all(eq(0))
|
||||||
|
expect(result.fetch(:websocket).map { |level| level.fetch(:errors) }).to all(eq(0))
|
||||||
|
expect(result.fetch(:websocket).map { |level| level.fetch(:opened) }).to eq([1, 2])
|
||||||
|
expect(result.dig(:metrics, :action_cable_connections_total)).to be >= 2
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
@ -0,0 +1,97 @@
|
||||||
|
# frozen_string_literal: true
|
||||||
|
|
||||||
|
require 'spec_helper'
|
||||||
|
require 'securerandom'
|
||||||
|
require_relative '../../../e2e/lib/action_cable_client'
|
||||||
|
require_relative '../../../e2e/lib/api_client'
|
||||||
|
|
||||||
|
RSpec.describe 'Metrics HTTP E2E' do
|
||||||
|
primary_env_vars = %w[TURNIERE_E2E_EMAIL TURNIERE_E2E_PASSWORD].freeze
|
||||||
|
|
||||||
|
before do
|
||||||
|
missing_vars = primary_env_vars.reject { |name| ENV.key?(name) }
|
||||||
|
skip("HTTP E2E requires #{missing_vars.join(', ')}") unless missing_vars.empty?
|
||||||
|
end
|
||||||
|
|
||||||
|
let(:base_url) { ENV.fetch('TURNIERE_E2E_BASE_URL', 'http://127.0.0.1:3000') }
|
||||||
|
let(:owner_email) { ENV.fetch('TURNIERE_E2E_EMAIL') }
|
||||||
|
let(:owner_password) { ENV.fetch('TURNIERE_E2E_PASSWORD') }
|
||||||
|
let(:anonymous_client) { TurniereE2E::ApiClient.new(base_url:) }
|
||||||
|
|
||||||
|
it 'exposes HTTP request metrics and live websocket connection metrics' do
|
||||||
|
health_response = anonymous_client.get('/healthz')
|
||||||
|
metrics_response = anonymous_client.get('/metrics')
|
||||||
|
|
||||||
|
expect(health_response[:status]).to eq(200)
|
||||||
|
expect(metrics_response[:status]).to eq(200)
|
||||||
|
expect(metrics_response.dig(:headers, 'content-type')).to include('text/plain')
|
||||||
|
|
||||||
|
metrics_body = raw_body(metrics_response)
|
||||||
|
expect(metrics_body).to include('# TYPE turniere_http_requests_total counter')
|
||||||
|
expect(metrics_body).to match(
|
||||||
|
/turniere_http_requests_total\{
|
||||||
|
(?=[^}]*method="GET")
|
||||||
|
(?=[^}]*controller="health")
|
||||||
|
(?=[^}]*action="index")
|
||||||
|
(?=[^}]*status="200")
|
||||||
|
[^}]*
|
||||||
|
\}\ [1-9]\d*/x
|
||||||
|
)
|
||||||
|
expect(metrics_body).to include('# TYPE turniere_http_request_duration_seconds histogram')
|
||||||
|
|
||||||
|
tournament = create_playoff_tournament
|
||||||
|
cable_client = TurniereE2E::ActionCableClient.new(base_url:)
|
||||||
|
cable_client.subscribe_timer!(tournament_id: tournament.fetch(:id))
|
||||||
|
|
||||||
|
expect(metric_value('turniere_action_cable_connections')).to be >= 1
|
||||||
|
expect(metric_value('turniere_action_cable_connections_total')).to be >= 1
|
||||||
|
ensure
|
||||||
|
cable_client&.close
|
||||||
|
end
|
||||||
|
|
||||||
|
def create_playoff_tournament
|
||||||
|
owner_client = login_client
|
||||||
|
unique = SecureRandom.hex(3)
|
||||||
|
response = owner_client.post('/tournaments', body: {
|
||||||
|
name: "Metrics #{unique}",
|
||||||
|
public: false,
|
||||||
|
teams: Array.new(4) { |index| { name: "Metrics-#{unique}-#{index + 1}" } }
|
||||||
|
})
|
||||||
|
|
||||||
|
expect(response[:status]).to eq(201)
|
||||||
|
response.fetch(:json)
|
||||||
|
end
|
||||||
|
|
||||||
|
def login_client
|
||||||
|
client = TurniereE2E::ApiClient.new(base_url:)
|
||||||
|
response = client.login!(email: owner_email, password: owner_password)
|
||||||
|
|
||||||
|
expect(response[:status]).to eq(200)
|
||||||
|
client
|
||||||
|
end
|
||||||
|
|
||||||
|
def metric_value(metric_name)
|
||||||
|
deadline = Process.clock_gettime(Process::CLOCK_MONOTONIC) + 10
|
||||||
|
loop do
|
||||||
|
value = latest_metrics_lines
|
||||||
|
.grep(/\A#{Regexp.escape(metric_name)}(?:\{| )/)
|
||||||
|
.map { |line| line.split.last.to_f }
|
||||||
|
.max
|
||||||
|
return value unless value.nil?
|
||||||
|
|
||||||
|
raise "#{metric_name} not reported" if Process.clock_gettime(Process::CLOCK_MONOTONIC) >= deadline
|
||||||
|
|
||||||
|
sleep 0.2
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
def latest_metrics_lines
|
||||||
|
response = anonymous_client.get('/metrics')
|
||||||
|
expect(response[:status]).to eq(200)
|
||||||
|
raw_body(response).lines.map(&:strip)
|
||||||
|
end
|
||||||
|
|
||||||
|
def raw_body(response)
|
||||||
|
response.dig(:json, :raw_body).to_s
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
@ -86,7 +86,7 @@ RSpec.describe 'Tournament beamer websocket HTTP E2E' do
|
||||||
name: "#{name_prefix} #{SecureRandom.hex(3)}",
|
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
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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
Loading…
Reference in New Issue