diff --git a/.gitlab-ci.yml b/.gitlab-ci.yml index 2f8adb0..b0c5c3a 100644 --- a/.gitlab-ci.yml +++ b/.gitlab-ci.yml @@ -70,13 +70,17 @@ rails e2e spec: alias: postgres - name: $CI_REGISTRY_IMAGE/production/commits:$CI_COMMIT_SHA alias: app + variables: + TURNIERE_DISABLE_EMAIL_DELIVERY: "1" + WEB_CONCURRENCY: "1" + RAILS_MAX_THREADS: "5" + RAILS_DB_POOL: "5" + SIDEKIQ_CONCURRENCY: "5" command: - bundle - exec - rails - s - - -p - - "3000" - -b - 0.0.0.0 variables: @@ -84,6 +88,7 @@ rails e2e spec: POSTGRES_DB: turniere_blackbox POSTGRES_USER: turniere POSTGRES_PASSWORD: turniere + DATABASE_URL: postgres://turniere:turniere@postgres:5432/turniere_blackbox POSTGRES_URL: postgres://turniere:turniere@postgres:5432/turniere_blackbox POSTGRES_USERNAME: turniere SECRET_KEY_BASE: turniere-blackbox-secret-key-base-please-change-in-real-prod @@ -111,6 +116,69 @@ rails e2e spec: - cd /app - inv blackbox-service +rails load smoke: + stage: e2e + tags: + - docker + needs: + - job: build_image + image: $CI_REGISTRY_IMAGE/test/commits:$CI_COMMIT_SHA + services: + - name: postgres:16 + alias: postgres + - name: $CI_REGISTRY_IMAGE/production/commits:$CI_COMMIT_SHA + alias: app + variables: + TURNIERE_DISABLE_EMAIL_DELIVERY: "1" + WEB_CONCURRENCY: "1" + RAILS_MAX_THREADS: "5" + RAILS_DB_POOL: "5" + SIDEKIQ_CONCURRENCY: "5" + command: + - bundle + - exec + - rails + - s + - -b + - 0.0.0.0 + variables: + FF_NETWORK_PER_BUILD: "true" + POSTGRES_DB: turniere_blackbox + POSTGRES_USER: turniere + POSTGRES_PASSWORD: turniere + DATABASE_URL: postgres://turniere:turniere@postgres:5432/turniere_blackbox + POSTGRES_URL: postgres://turniere:turniere@postgres:5432/turniere_blackbox + POSTGRES_USERNAME: turniere + SECRET_KEY_BASE: turniere-blackbox-secret-key-base-please-change-in-real-prod + MAILGUN_API_KEY: blackbox-test-api-key + MAILGUN_DOMAIN: blackbox.example.com + RAILS_LOG_TO_STDOUT: "1" + RAILS_SERVE_STATIC_FILES: "1" + NO_PROXY: app,postgres,localhost,127.0.0.1 + no_proxy: app,postgres,localhost,127.0.0.1 + TURNIERE_E2E_BASE_URL: http://app:3000 + TURNIERE_E2E_EMAIL: e2e@example.com + TURNIERE_E2E_PASSWORD: password123 + TURNIERE_E2E_USERNAME: e2e-user + TURNIERE_LOAD_HTTP_CLIENTS: "1,10,50,100" + TURNIERE_LOAD_WEBSOCKET_CLIENTS: "1,10,50,100,200" + TURNIERE_LOAD_HTTP_REQUESTS_PER_CLIENT: "3" + TURNIERE_LOAD_WEBSOCKET_HOLD_SECONDS: "1.0" + TURNIERE_LOAD_WEBSOCKET_CONNECT_BATCH_SIZE: "25" + rules: + - if: $SKIP_TEST + when: never + - if: $SKIP_ALL_SPECS + when: never + - if: $SKIP_LOAD_SPEC + when: never + - if: $CI_PIPELINE_SOURCE != "push" + when: never + - when: always + script: + - cd /app + - inv blackbox-load-service + rails follow sync e2e spec: stage: e2e tags: @@ -134,47 +202,55 @@ rails follow sync e2e spec: - name: $CI_REGISTRY_IMAGE/production/commits:$CI_COMMIT_SHA alias: source-app variables: + DATABASE_URL: postgres://turniere:turniere@source-postgres:5432/turniere_source_blackbox POSTGRES_URL: postgres://turniere:turniere@source-postgres:5432/turniere_source_blackbox POSTGRES_USERNAME: turniere POSTGRES_PASSWORD: turniere SECRET_KEY_BASE: turniere-blackbox-secret-key-base-please-change-in-real-prod MAILGUN_API_KEY: blackbox-test-api-key MAILGUN_DOMAIN: blackbox.example.com + TURNIERE_DISABLE_EMAIL_DELIVERY: "1" TOURNAMENT_SYNC_HTTP_TIMEOUT_SECONDS: "3" NO_PROXY: source-app,follower-app,source-postgres,follower-postgres,localhost,127.0.0.1 no_proxy: source-app,follower-app,source-postgres,follower-postgres,localhost,127.0.0.1 RAILS_LOG_TO_STDOUT: "1" RAILS_SERVE_STATIC_FILES: "1" + WEB_CONCURRENCY: "1" + RAILS_MAX_THREADS: "5" + RAILS_DB_POOL: "5" + SIDEKIQ_CONCURRENCY: "5" command: - bundle - exec - rails - s - - -p - - "3000" - -b - 0.0.0.0 - name: $CI_REGISTRY_IMAGE/production/commits:$CI_COMMIT_SHA alias: follower-app variables: + DATABASE_URL: postgres://turniere:turniere@follower-postgres:5432/turniere_follower_blackbox POSTGRES_URL: postgres://turniere:turniere@follower-postgres:5432/turniere_follower_blackbox POSTGRES_USERNAME: turniere POSTGRES_PASSWORD: turniere SECRET_KEY_BASE: turniere-blackbox-secret-key-base-please-change-in-real-prod MAILGUN_API_KEY: blackbox-test-api-key MAILGUN_DOMAIN: blackbox.example.com + TURNIERE_DISABLE_EMAIL_DELIVERY: "1" TOURNAMENT_SYNC_HTTP_TIMEOUT_SECONDS: "3" NO_PROXY: source-app,follower-app,source-postgres,follower-postgres,localhost,127.0.0.1 no_proxy: source-app,follower-app,source-postgres,follower-postgres,localhost,127.0.0.1 RAILS_LOG_TO_STDOUT: "1" RAILS_SERVE_STATIC_FILES: "1" + WEB_CONCURRENCY: "1" + RAILS_MAX_THREADS: "5" + RAILS_DB_POOL: "5" + SIDEKIQ_CONCURRENCY: "5" command: - bundle - exec - rails - s - - -p - - "3000" - -b - 0.0.0.0 variables: diff --git a/.rubocop.yml b/.rubocop.yml index 89e4f1f..0c74afb 100644 --- a/.rubocop.yml +++ b/.rubocop.yml @@ -1,4 +1,4 @@ -require: +plugins: - rubocop-performance - rubocop-rails @@ -9,6 +9,7 @@ AllCops: Exclude: - "bin/**/*" - "db/schema.rb" + - "spec/**/*.rb" # Enable Rails Rails: @@ -64,12 +65,10 @@ Rails/SkipsModelValidations: - "app/models/tournament_sync_queue_entry.rb" - "app/services/tournament_sync_pusher.rb" - "db/migrate/**/*" - - "spec/controllers/tournaments_controller_spec.rb" Style/MultilineBlockChain: Exclude: - "e2e/lib/api_client.rb" - - "spec/e2e/http/tournament_follow_sync_spec.rb" # Disable documentation checks for now # should be done with yard instead diff --git a/.ruby-version b/.ruby-version index ef538c2..c4e41f9 100644 --- a/.ruby-version +++ b/.ruby-version @@ -1 +1 @@ -3.1.2 +4.0.3 diff --git a/Dockerfile b/Dockerfile index 7e7d590..e7c10d1 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,11 +1,13 @@ # syntax=docker/dockerfile:1.7 -ARG RUBY_VERSION=3.1.2 -ARG BUNDLER_VERSION=2.3.13 +ARG RUBY_VERSION=4.0.3 +ARG BUNDLER_VERSION=4.0.6 +ARG base_commit=aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa FROM ruby:${RUBY_VERSION}-slim AS runtime-base ARG BUNDLER_VERSION +ARG DEBIAN_FRONTEND=noninteractive ENV LANG=C.UTF-8 \ BUNDLE_JOBS=4 \ @@ -18,6 +20,7 @@ WORKDIR /app RUN apt-get update -qq \ && apt-get install -y --no-install-recommends \ libpq5 \ + libyaml-0-2 \ nodejs \ openssl \ && rm -rf /var/lib/apt/lists/* \ @@ -26,15 +29,17 @@ RUN apt-get update -qq \ FROM runtime-base AS build-base +ARG DEBIAN_FRONTEND=noninteractive + RUN apt-get update -qq \ && apt-get install -y --no-install-recommends \ build-essential \ git \ libpq-dev \ libsqlite3-dev \ + libyaml-dev \ pkg-config \ python3 \ - python3-pip \ && rm -rf /var/lib/apt/lists/* COPY Gemfile Gemfile.lock ./ @@ -54,13 +59,14 @@ RUN bundle config set with 'development test' \ FROM runtime-base AS production -ARG GIT_COMMIT_SHA=aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa +ARG base_commit=aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa -ENV RAILS_ENV=production -ENV GIT_COMMIT_SHA=${GIT_COMMIT_SHA} +ENV RAILS_ENV=production \ + GIT_COMMIT_SHA=${base_commit} COPY --from=bundle-production /usr/local/bundle /usr/local/bundle COPY --from=bundle-production /app/vendor/bundle /app/vendor/bundle + COPY Gemfile Gemfile.lock /app/ COPY app /app/app COPY bin /app/bin @@ -69,30 +75,32 @@ COPY db /app/db COPY public /app/public COPY script /app/script COPY config.ru Rakefile /app/ + # Duplicate production environment to beta environment COPY config/environments/production.rb /app/config/environments/beta.rb EXPOSE 3000 -CMD ["sh", "-lc", "bundle exec rails db:migrate && bundle exec rails s -p 3000 -b 0.0.0.0"] +CMD ["sh", "-lc", "bundle exec rails db:migrate && bundle exec rails s -b 0.0.0.0"] FROM runtime-base AS test -ARG GIT_COMMIT_SHA=aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa +ARG base_commit=aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa +ARG DEBIAN_FRONTEND=noninteractive -ENV RAILS_ENV=test -ENV GIT_COMMIT_SHA=${GIT_COMMIT_SHA} +ENV RAILS_ENV=test \ + GIT_COMMIT_SHA=${base_commit} RUN apt-get update -qq \ && apt-get install -y --no-install-recommends \ libsqlite3-0 \ python3 \ - python3-pip \ - && rm -rf /var/lib/apt/lists/* \ - && python3 -m pip install --no-cache-dir invoke + python3-invoke \ + && rm -rf /var/lib/apt/lists/* COPY --from=bundle-test /usr/local/bundle /usr/local/bundle COPY --from=bundle-test /app/vendor/bundle /app/vendor/bundle + COPY Gemfile Gemfile.lock /app/ COPY app /app/app COPY bin /app/bin diff --git a/Gemfile b/Gemfile index 9ed58ef..7fdce7e 100644 --- a/Gemfile +++ b/Gemfile @@ -3,12 +3,12 @@ source 'https://rubygems.org' git_source(:github) { |repo| "https://github.com/#{repo}.git" } -ruby '3.1.2' +ruby '4.0.3' # Bundle edge Rails instead: gem 'rails', github: 'rails/rails' -gem 'rails', '~> 7.0.3' +gem 'rails', '~> 8.1' # Use Puma as the app server -gem 'puma', '~> 5.0' +gem 'puma', '~> 8.0' # Build JSON APIs with ease. Read more: https://github.com/rails/jbuilder # gem 'jbuilder', '~> 2.5' # Use ActiveModel has_secure_password @@ -28,7 +28,7 @@ gem 'bootsnap', require: false # Authentication gem 'devise' -gem 'devise_token_auth', git: 'https://github.com/Thor77/devise_token_auth', branch: 'fix-api-only-mode' +gem 'devise_token_auth' gem 'rack-cors' @@ -58,7 +58,7 @@ group :development, optional: true do gem 'rubocop-rails' gem 'solargraph' gem 'spring' - gem 'spring-watcher-listen', '~> 2.0.0' + gem 'spring-watcher-listen', '~> 2.1' gem 'sqlite3' end @@ -67,4 +67,4 @@ group :production do end # Windows does not include zoneinfo files, so bundle the tzinfo-data gem -gem 'tzinfo-data', platforms: %i[mingw mswin x64_mingw jruby] +gem 'tzinfo-data', platforms: %i[windows jruby] diff --git a/Gemfile.lock b/Gemfile.lock index 15088d9..b38a9d2 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -1,326 +1,407 @@ -GIT - remote: https://github.com/Thor77/devise_token_auth - revision: 30ba21add5c87b2964b909c6fc5cd18cfeae2191 - branch: fix-api-only-mode - specs: - devise_token_auth (1.2.0) - bcrypt (~> 3.0) - devise (> 3.5.2, < 5) - rails (>= 4.2.0, < 7.1) - GEM remote: https://rubygems.org/ specs: - actioncable (7.0.8.1) - actionpack (= 7.0.8.1) - activesupport (= 7.0.8.1) + action_text-trix (2.1.18) + railties + actioncable (8.1.3) + actionpack (= 8.1.3) + activesupport (= 8.1.3) nio4r (~> 2.0) websocket-driver (>= 0.6.1) - actionmailbox (7.0.8.1) - actionpack (= 7.0.8.1) - activejob (= 7.0.8.1) - activerecord (= 7.0.8.1) - activestorage (= 7.0.8.1) - activesupport (= 7.0.8.1) - mail (>= 2.7.1) - net-imap - net-pop - net-smtp - actionmailer (7.0.8.1) - actionpack (= 7.0.8.1) - actionview (= 7.0.8.1) - activejob (= 7.0.8.1) - activesupport (= 7.0.8.1) - mail (~> 2.5, >= 2.5.4) - net-imap - net-pop - net-smtp - rails-dom-testing (~> 2.0) - actionpack (7.0.8.1) - actionview (= 7.0.8.1) - activesupport (= 7.0.8.1) - rack (~> 2.0, >= 2.2.4) + zeitwerk (~> 2.6) + actionmailbox (8.1.3) + actionpack (= 8.1.3) + activejob (= 8.1.3) + activerecord (= 8.1.3) + activestorage (= 8.1.3) + activesupport (= 8.1.3) + mail (>= 2.8.0) + actionmailer (8.1.3) + actionpack (= 8.1.3) + actionview (= 8.1.3) + activejob (= 8.1.3) + activesupport (= 8.1.3) + mail (>= 2.8.0) + rails-dom-testing (~> 2.2) + actionpack (8.1.3) + actionview (= 8.1.3) + activesupport (= 8.1.3) + nokogiri (>= 1.8.5) + rack (>= 2.2.4) + rack-session (>= 1.0.1) rack-test (>= 0.6.3) - rails-dom-testing (~> 2.0) - rails-html-sanitizer (~> 1.0, >= 1.2.0) - actiontext (7.0.8.1) - actionpack (= 7.0.8.1) - activerecord (= 7.0.8.1) - activestorage (= 7.0.8.1) - activesupport (= 7.0.8.1) + rails-dom-testing (~> 2.2) + rails-html-sanitizer (~> 1.6) + useragent (~> 0.16) + actiontext (8.1.3) + action_text-trix (~> 2.1.15) + actionpack (= 8.1.3) + activerecord (= 8.1.3) + activestorage (= 8.1.3) + activesupport (= 8.1.3) globalid (>= 0.6.0) nokogiri (>= 1.8.5) - actionview (7.0.8.1) - activesupport (= 7.0.8.1) + actionview (8.1.3) + activesupport (= 8.1.3) builder (~> 3.1) - erubi (~> 1.4) - rails-dom-testing (~> 2.0) - rails-html-sanitizer (~> 1.1, >= 1.2.0) - active_model_serializers (0.10.14) + erubi (~> 1.11) + rails-dom-testing (~> 2.2) + rails-html-sanitizer (~> 1.6) + active_model_serializers (0.10.16) actionpack (>= 4.1) activemodel (>= 4.1) case_transform (>= 0.2) jsonapi-renderer (>= 0.1.1.beta1, < 0.3) - activejob (7.0.8.1) - activesupport (= 7.0.8.1) + activejob (8.1.3) + activesupport (= 8.1.3) globalid (>= 0.3.6) - activemodel (7.0.8.1) - activesupport (= 7.0.8.1) - activerecord (7.0.8.1) - activemodel (= 7.0.8.1) - activesupport (= 7.0.8.1) - activestorage (7.0.8.1) - actionpack (= 7.0.8.1) - activejob (= 7.0.8.1) - activerecord (= 7.0.8.1) - activesupport (= 7.0.8.1) + activemodel (8.1.3) + activesupport (= 8.1.3) + activerecord (8.1.3) + activemodel (= 8.1.3) + activesupport (= 8.1.3) + timeout (>= 0.4.0) + activestorage (8.1.3) + actionpack (= 8.1.3) + activejob (= 8.1.3) + activerecord (= 8.1.3) + activesupport (= 8.1.3) marcel (~> 1.0) - mini_mime (>= 1.1.0) - activesupport (7.0.8.1) - concurrent-ruby (~> 1.0, >= 1.0.2) + activesupport (8.1.3) + base64 + bigdecimal + concurrent-ruby (~> 1.0, >= 1.3.1) + connection_pool (>= 2.2.5) + drb i18n (>= 1.6, < 2) + json + logger (>= 1.4.2) minitest (>= 5.1) - tzinfo (~> 2.0) - ast (2.4.2) + securerandom (>= 0.3) + tzinfo (~> 2.0, >= 2.0.5) + uri (>= 0.13.1) + ast (2.4.3) backport (1.2.0) - bcrypt (3.1.20) - benchmark (0.3.0) - bootsnap (1.18.3) + base64 (0.3.0) + bcrypt (3.1.22) + benchmark (0.5.0) + bigdecimal (4.1.2) + bootsnap (1.24.0) msgpack (~> 1.2) - builder (3.2.4) + builder (3.3.0) case_transform (0.2) activesupport - concurrent-ruby (1.2.3) + concurrent-ruby (1.3.6) + connection_pool (3.0.2) crass (1.0.6) - date (3.3.4) - devise (4.9.3) + date (3.5.1) + devise (4.9.4) bcrypt (~> 3.0) orm_adapter (~> 0.1) railties (>= 4.1.0) responders warden (~> 1.2.3) - diff-lcs (1.5.1) - domain_name (0.6.20240107) - e2mmap (0.1.0) - erubi (1.12.0) - factory_bot (6.4.6) - activesupport (>= 5.0.0) - factory_bot_rails (6.4.3) - factory_bot (~> 6.4) - railties (>= 5.0.0) - faker (3.3.1) + devise_token_auth (1.2.6) + bcrypt (~> 3.0) + devise (> 3.5.2, < 5) + rails (>= 4.2.0, < 8.2) + diff-lcs (1.6.2) + drb (2.2.3) + erb (6.0.4) + erubi (1.13.1) + factory_bot (6.5.6) + activesupport (>= 6.1.0) + factory_bot_rails (6.5.1) + factory_bot (~> 6.5) + railties (>= 6.1.0) + faker (3.8.0) i18n (>= 1.8.11, < 2) - ffi (1.16.3) - globalid (1.2.1) + faraday (2.14.1) + faraday-net_http (>= 2.0, < 3.5) + json + logger + faraday-multipart (1.2.0) + multipart-post (~> 2.0) + faraday-net_http (3.4.2) + net-http (~> 0.5) + ffi (1.17.4-aarch64-linux-gnu) + ffi (1.17.4-aarch64-linux-musl) + ffi (1.17.4-arm64-darwin) + ffi (1.17.4-x86_64-linux-gnu) + globalid (1.3.0) activesupport (>= 6.1) - http-accept (1.7.0) - http-cookie (1.0.5) - domain_name (~> 0.5) - i18n (1.14.4) + i18n (1.14.8) concurrent-ruby (~> 1.0) - interactor (3.1.2) - interactor-rails (2.2.1) + interactor (3.2.0) + ostruct + interactor-rails (2.3.0) interactor (~> 3.0) - rails (>= 4.2) - jaro_winkler (1.5.6) - json (2.7.2) + railties (>= 7.0) + io-console (0.8.2) + irb (1.18.0) + pp (>= 0.6.0) + prism (>= 1.3.0) + rdoc (>= 4.0.0) + reline (>= 0.4.2) + jaro_winkler (1.7.0) + json (2.19.4) jsonapi-renderer (0.2.2) - kramdown (2.4.0) - rexml + kramdown (2.5.2) + rexml (>= 3.4.4) kramdown-parser-gfm (1.1.0) kramdown (~> 2.0) - language_server-protocol (3.17.0.3) - listen (3.9.0) + language_server-protocol (3.17.0.5) + lint_roller (1.1.0) + listen (3.10.0) + logger rb-fsevent (~> 0.10, >= 0.10.3) rb-inotify (~> 0.9, >= 0.9.10) - loofah (2.22.0) + logger (1.7.0) + loofah (2.25.1) crass (~> 1.0.2) nokogiri (>= 1.12.0) - mail (2.8.1) + mail (2.9.0) + logger mini_mime (>= 0.1.1) net-imap net-pop net-smtp - mailgun-ruby (1.2.14) - rest-client (>= 2.0.2) - marcel (1.0.4) - method_source (1.0.0) - mime-types (3.5.2) - mime-types-data (~> 3.2015) - mime-types-data (3.2024.0305) + mailgun-ruby (1.4.3) + faraday (~> 2.1) + faraday-multipart (< 2) + mini_mime + zeitwerk + marcel (1.1.0) mini_mime (1.1.5) - minitest (5.22.3) - msgpack (1.7.2) - net-imap (0.4.10) + minitest (6.0.5) + drb (~> 2.0) + prism (~> 1.5) + msgpack (1.8.0) + multipart-post (2.4.1) + net-http (0.9.1) + uri (>= 0.11.1) + net-imap (0.6.4) date net-protocol net-pop (0.1.2) net-protocol net-protocol (0.2.2) timeout - net-smtp (0.5.0) + net-smtp (0.5.1) net-protocol - netrc (0.11.0) - nio4r (2.7.1) - nokogiri (1.16.3-aarch64-linux) + nio4r (2.7.5) + nokogiri (1.19.3-aarch64-linux-gnu) racc (~> 1.4) - nokogiri (1.16.3-arm64-darwin) + nokogiri (1.19.3-aarch64-linux-musl) racc (~> 1.4) - nokogiri (1.16.3-x86_64-linux) + nokogiri (1.19.3-arm64-darwin) racc (~> 1.4) + nokogiri (1.19.3-x86_64-linux-gnu) + racc (~> 1.4) + observer (0.1.2) + open3 (0.2.1) orm_adapter (0.5.0) - parallel (1.24.0) - parser (3.3.0.5) + ostruct (0.6.3) + parallel (2.1.0) + parser (3.3.11.1) ast (~> 2.4.1) racc - pg (1.5.6) - puma (5.6.8) + pg (1.6.3-aarch64-linux) + pg (1.6.3-aarch64-linux-musl) + pg (1.6.3-arm64-darwin) + pg (1.6.3-x86_64-linux) + pp (0.6.3) + prettyprint + prettyprint (0.2.0) + prism (1.9.0) + psych (5.3.1) + date + stringio + puma (8.0.1) nio4r (~> 2.0) - racc (1.7.3) - rack (2.2.9) - rack-cors (2.0.2) - rack (>= 2.0.0) - rack-test (2.1.0) + racc (1.8.1) + rack (3.2.6) + rack-cors (3.0.0) + logger + rack (>= 3.0.14) + rack-session (2.1.2) + base64 (>= 0.1.0) + rack (>= 3.0.0) + rack-test (2.2.0) rack (>= 1.3) + rackup (2.3.1) + rack (>= 3) railroady (1.6.0) - rails (7.0.8.1) - actioncable (= 7.0.8.1) - actionmailbox (= 7.0.8.1) - actionmailer (= 7.0.8.1) - actionpack (= 7.0.8.1) - actiontext (= 7.0.8.1) - actionview (= 7.0.8.1) - activejob (= 7.0.8.1) - activemodel (= 7.0.8.1) - activerecord (= 7.0.8.1) - activestorage (= 7.0.8.1) - activesupport (= 7.0.8.1) + rails (8.1.3) + actioncable (= 8.1.3) + actionmailbox (= 8.1.3) + actionmailer (= 8.1.3) + actionpack (= 8.1.3) + actiontext (= 8.1.3) + actionview (= 8.1.3) + activejob (= 8.1.3) + activemodel (= 8.1.3) + activerecord (= 8.1.3) + activestorage (= 8.1.3) + activesupport (= 8.1.3) bundler (>= 1.15.0) - railties (= 7.0.8.1) - rails-dom-testing (2.2.0) + railties (= 8.1.3) + rails-dom-testing (2.3.0) activesupport (>= 5.0.0) minitest nokogiri (>= 1.6) - rails-html-sanitizer (1.6.0) - loofah (~> 2.21) - nokogiri (~> 1.14) - railties (7.0.8.1) - actionpack (= 7.0.8.1) - activesupport (= 7.0.8.1) - method_source + rails-html-sanitizer (1.7.0) + loofah (~> 2.25) + nokogiri (>= 1.15.7, != 1.16.7, != 1.16.6, != 1.16.5, != 1.16.4, != 1.16.3, != 1.16.2, != 1.16.1, != 1.16.0.rc1, != 1.16.0) + railties (8.1.3) + actionpack (= 8.1.3) + activesupport (= 8.1.3) + irb (~> 1.13) + rackup (>= 1.0.0) rake (>= 12.2) - thor (~> 1.0) - zeitwerk (~> 2.5) + thor (~> 1.0, >= 1.2.2) + tsort (>= 0.2) + zeitwerk (~> 2.6) rainbow (3.1.1) - rake (13.2.1) + rake (13.4.2) rb-fsevent (0.11.2) - rb-inotify (0.10.1) + rb-inotify (0.11.1) ffi (~> 1.0) - rbs (2.8.4) - regexp_parser (2.9.0) - responders (3.1.1) - actionpack (>= 5.2) - railties (>= 5.2) - rest-client (2.1.0) - http-accept (>= 1.7.0, < 2.0) - http-cookie (>= 1.0.2, < 2.0) - mime-types (>= 1.16, < 4.0) - netrc (~> 0.8) - reverse_markdown (2.1.1) + rbs (3.10.4) + logger + tsort + rdoc (7.2.0) + erb + psych (>= 4.0.0) + tsort + regexp_parser (2.12.0) + reline (0.6.3) + io-console (~> 0.5) + responders (3.2.0) + actionpack (>= 7.0) + railties (>= 7.0) + reverse_markdown (3.0.2) nokogiri - rexml (3.2.6) - rspec-core (3.13.0) + rexml (3.4.4) + rspec-core (3.13.6) rspec-support (~> 3.13.0) - rspec-expectations (3.13.0) + rspec-expectations (3.13.5) diff-lcs (>= 1.2.0, < 2.0) rspec-support (~> 3.13.0) - rspec-mocks (3.13.0) + rspec-mocks (3.13.8) diff-lcs (>= 1.2.0, < 2.0) rspec-support (~> 3.13.0) - rspec-rails (6.1.2) - actionpack (>= 6.1) - activesupport (>= 6.1) - railties (>= 6.1) - rspec-core (~> 3.13) - rspec-expectations (~> 3.13) - rspec-mocks (~> 3.13) - rspec-support (~> 3.13) - rspec-support (3.13.1) - rubocop (1.62.1) + rspec-rails (8.0.4) + actionpack (>= 7.2) + activesupport (>= 7.2) + railties (>= 7.2) + rspec-core (>= 3.13.0, < 5.0.0) + rspec-expectations (>= 3.13.0, < 5.0.0) + rspec-mocks (>= 3.13.0, < 5.0.0) + rspec-support (>= 3.13.0, < 5.0.0) + rspec-support (3.13.7) + rubocop (1.86.1) json (~> 2.3) - language_server-protocol (>= 3.17.0) - parallel (~> 1.10) + language_server-protocol (~> 3.17.0.2) + lint_roller (~> 1.1.0) + parallel (>= 1.10) parser (>= 3.3.0.2) rainbow (>= 2.2.2, < 4.0) - regexp_parser (>= 1.8, < 3.0) - rexml (>= 3.2.5, < 4.0) - rubocop-ast (>= 1.31.1, < 2.0) + regexp_parser (>= 2.9.3, < 3.0) + rubocop-ast (>= 1.49.0, < 2.0) ruby-progressbar (~> 1.7) - unicode-display_width (>= 2.4.0, < 3.0) - rubocop-ast (1.31.2) - parser (>= 3.3.0.4) - rubocop-performance (1.21.0) - rubocop (>= 1.48.1, < 2.0) - rubocop-ast (>= 1.31.1, < 2.0) - rubocop-rails (2.24.1) + unicode-display_width (>= 2.4.0, < 4.0) + rubocop-ast (1.49.1) + parser (>= 3.3.7.2) + prism (~> 1.7) + rubocop-performance (1.26.1) + lint_roller (~> 1.1) + rubocop (>= 1.75.0, < 2.0) + rubocop-ast (>= 1.47.1, < 2.0) + rubocop-rails (2.34.3) activesupport (>= 4.2.0) + lint_roller (~> 1.1) rack (>= 1.1) - rubocop (>= 1.33.0, < 2.0) - rubocop-ast (>= 1.31.1, < 2.0) - ruby-prof (1.7.1) + rubocop (>= 1.75.0, < 2.0) + rubocop-ast (>= 1.44.0, < 2.0) + ruby-prof (2.0.4) + base64 + ostruct ruby-progressbar (1.13.0) - shoulda-matchers (6.2.0) - activesupport (>= 5.2.0) - solargraph (0.50.0) + securerandom (0.4.1) + shoulda-matchers (7.0.1) + activesupport (>= 7.1) + solargraph (0.58.3) + ast (~> 2.4.3) backport (~> 1.2) - benchmark - bundler (~> 2.0) + benchmark (~> 0.4) + bundler (>= 2.0) diff-lcs (~> 1.4) - e2mmap - jaro_winkler (~> 1.5) + jaro_winkler (~> 1.6, >= 1.6.1) kramdown (~> 2.3) kramdown-parser-gfm (~> 1.1) + logger (~> 1.6) + observer (~> 0.1) + open3 (~> 0.2.1) + ostruct (~> 0.6) parser (~> 3.0) - rbs (~> 2.0) - reverse_markdown (~> 2.0) - rubocop (~> 1.38) + prism (~> 1.4) + rbs (>= 3.6.1, <= 4.0.0.dev.4) + reverse_markdown (~> 3.0) + rubocop (~> 1.76) thor (~> 1.0) tilt (~> 2.0) yard (~> 0.9, >= 0.9.24) - spring (2.1.1) - spring-watcher-listen (2.0.1) + yard-activesupport-concern (~> 0.0) + yard-solargraph (~> 0.1) + spring (4.4.2) + spring-watcher-listen (2.1.0) listen (>= 2.7, < 4.0) - spring (>= 1.2, < 3.0) - sqlite3 (1.7.3-aarch64-linux) - sqlite3 (1.7.3-arm64-darwin) - sqlite3 (1.7.3-x86_64-linux) - thor (1.3.1) - tilt (2.3.0) - timeout (0.4.1) + spring (>= 4) + sqlite3 (2.9.3-aarch64-linux-gnu) + sqlite3 (2.9.3-aarch64-linux-musl) + sqlite3 (2.9.3-arm64-darwin) + sqlite3 (2.9.3-x86_64-linux-gnu) + stringio (3.2.0) + thor (1.5.0) + tilt (2.7.0) + timeout (0.6.1) + tsort (0.2.0) tzinfo (2.0.6) concurrent-ruby (~> 1.0) - unicode-display_width (2.5.0) + unicode-display_width (3.2.0) + unicode-emoji (~> 4.1) + unicode-emoji (4.2.0) + uri (1.1.1) + useragent (0.16.11) warden (1.2.9) rack (>= 2.0.9) - websocket-driver (0.7.6) + websocket-driver (0.8.0) + base64 websocket-extensions (>= 0.1.0) websocket-extensions (0.1.5) - yard (0.9.36) - zeitwerk (2.6.13) + yard (0.9.43) + yard-activesupport-concern (0.0.1) + yard (>= 0.8) + yard-solargraph (0.1.0) + yard (~> 0.9) + zeitwerk (2.7.5) PLATFORMS + aarch64-linux aarch64-linux-musl arm64-darwin-22 arm64-darwin-23 arm64-darwin-24 arm64-darwin-25 x86_64-linux + x86_64-linux-gnu DEPENDENCIES active_model_serializers bootsnap devise - devise_token_auth! + devise_token_auth factory_bot_rails faker interactor @@ -328,10 +409,10 @@ DEPENDENCIES listen (~> 3.3) mailgun-ruby pg - puma (~> 5.0) + puma (~> 8.0) rack-cors railroady - rails (~> 7.0.3) + rails (~> 8.1) rspec-rails rubocop rubocop-performance @@ -340,12 +421,12 @@ DEPENDENCIES shoulda-matchers solargraph spring - spring-watcher-listen (~> 2.0.0) + spring-watcher-listen (~> 2.1) sqlite3 tzinfo-data RUBY VERSION - ruby 3.1.2p20 + ruby 4.0.3 BUNDLED WITH - 2.3.7 + 4.0.6 diff --git a/README.md b/README.md index 1a42f7f..5cab19a 100644 --- a/README.md +++ b/README.md @@ -30,6 +30,10 @@ bundle exec rspec Leader/follower tournament sync docs: - [doc/leader_follower.md](doc/leader_follower.md) +- [doc/account_profiles_api.md](doc/account_profiles_api.md) +- [doc/tournament_management_api.md](doc/tournament_management_api.md) +- [doc/match_metadata_api.md](doc/match_metadata_api.md) +- [doc/team_action_lists_api.md](doc/team_action_lists_api.md) - [doc/timer_api.md](doc/timer_api.md) - [doc/beamer_live_updates.md](doc/beamer_live_updates.md) - [doc/tournament_live_updates.md](doc/tournament_live_updates.md) diff --git a/app/channels/application_cable/connection.rb b/app/channels/application_cable/connection.rb index 8d6c2a1..347b72c 100644 --- a/app/channels/application_cable/connection.rb +++ b/app/channels/application_cable/connection.rb @@ -2,5 +2,12 @@ module ApplicationCable class Connection < ActionCable::Connection::Base + def connect + MetricsRegistry.action_cable_connected + end + + def disconnect + MetricsRegistry.action_cable_disconnected + end end end diff --git a/app/controllers/accounts_controller.rb b/app/controllers/accounts_controller.rb new file mode 100644 index 0000000..67f43be --- /dev/null +++ b/app/controllers/accounts_controller.rb @@ -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 diff --git a/app/controllers/application_controller.rb b/app/controllers/application_controller.rb index b1bddc6..a3451c5 100644 --- a/app/controllers/application_controller.rb +++ b/app/controllers/application_controller.rb @@ -3,11 +3,14 @@ class ApplicationController < ActionController::API include DeviseTokenAuth::Concerns::SetUserByToken + around_action :record_request_metrics before_action :configure_permitted_parameters, if: :devise_controller? rescue_from ActionController::ParameterMissing do |e| render json: { error: e.message }, status: :bad_request end + rescue_from ActiveRecord::RecordInvalid, with: :render_record_invalid_error + rescue_from ActiveRecord::RecordNotFound, with: :render_not_found_error protected @@ -18,6 +21,23 @@ class ApplicationController < ActionController::API private + def record_request_metrics + start_time = Process.clock_gettime(Process::CLOCK_MONOTONIC) + metrics_status = nil + yield + rescue StandardError => e + metrics_status = ActionDispatch::ExceptionWrapper.status_code_for_exception(e.class.name) + raise + ensure + MetricsRegistry.record_http_request( + method: request.request_method, + controller: controller_path, + action: action_name, + status: metrics_status || response.status, + duration: Process.clock_gettime(Process::CLOCK_MONOTONIC) - start_time + ) + end + def require_owner!(owner) render_forbidden_error if owner != current_user end @@ -37,10 +57,19 @@ class ApplicationController < ActionController::API end def require_writable_tournament!(tournament) - render_read_only_error if tournament.read_only_mode? + render_read_only_error if tournament&.read_only_mode? end def render_not_found_error(exception) - render json: { error: exception.to_s }, status: :not_found + error = if exception.is_a?(ActiveRecord::RecordNotFound) && exception.model.present? + "Couldn't find #{exception.model} with '#{exception.primary_key}'=#{exception.id.inspect}" + else + 'Record not found' + end + render json: { error: error }, status: :not_found + end + + def render_record_invalid_error(exception) + render json: exception.record.errors, status: :unprocessable_content end end diff --git a/app/controllers/beamers_controller.rb b/app/controllers/beamers_controller.rb index 5772eec..6c6c867 100644 --- a/app/controllers/beamers_controller.rb +++ b/app/controllers/beamers_controller.rb @@ -36,7 +36,7 @@ class BeamersController < ApplicationController push_sync_if_needed! render json: beamer, status: :created else - render json: beamer.errors, status: :unprocessable_entity + render json: beamer.errors, status: :unprocessable_content end end @@ -46,7 +46,7 @@ class BeamersController < ApplicationController push_sync_if_needed! render json: @beamer else - render json: @beamer.errors, status: :unprocessable_entity + render json: @beamer.errors, status: :unprocessable_content end end diff --git a/app/controllers/bets_controller.rb b/app/controllers/bets_controller.rb index 876bf56..dce34e8 100644 --- a/app/controllers/bets_controller.rb +++ b/app/controllers/bets_controller.rb @@ -25,6 +25,6 @@ class BetsController < ApplicationController end def handle_user_service_error(exception) - render json: { error: exception.message }, status: :unprocessable_entity + render json: { error: exception.message }, status: :unprocessable_content end end diff --git a/app/controllers/match_scores_controller.rb b/app/controllers/match_scores_controller.rb index 07f55bb..7d3af67 100644 --- a/app/controllers/match_scores_controller.rb +++ b/app/controllers/match_scores_controller.rb @@ -13,14 +13,38 @@ class MatchScoresController < ApplicationController # PATCH/PUT /scores/1 def update - if @match_score.update(match_score_params) - UpdateGroupsGroupScoresAndSave.call(group: @match_score.match.group) if @match_score.part_of_group_match? - push_sync_if_needed!(@match_score.tournament) - TournamentLiveBroadcast.call(@match_score.tournament.id) - render json: @match_score - else - render json: @match_score.errors, status: :unprocessable_entity + 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) + group_scores = [] + if @match_score.part_of_group_match? + UpdateGroupsGroupScoresAndSave.call(group: @match_score.match.group) + group_scores = group_scores_for_log(@match_score.match.group) + end + TournamentTransactionLogger.record_match_score_update!( + match_score: @match_score, + user: current_user, + changes: score_changes(old_values, @match_score), + group_scores: group_scores + ) + synced_tournament = tournament + render json: @match_score + else + render json: @match_score.errors, status: :unprocessable_content + raise ActiveRecord::Rollback + end end + + return if synced_tournament.nil? + + push_sync_if_needed!(synced_tournament) + TournamentLiveBroadcast.call(synced_tournament.id) end private @@ -35,6 +59,30 @@ class MatchScoresController < ApplicationController params.slice(:points, :hidden_points).permit! end + def score_values(match_score) + { + points: match_score.points, + hidden_points: match_score.hidden_points + } + end + + def score_changes(old_values, match_score) + permitted_params = match_score_params + score_values(match_score).each_with_object({}) do |(field, new_value), changes| + next unless permitted_params.key?(field) || permitted_params.key?(field.to_s) + next if old_values[field] == new_value + + changes[field] = { + from: old_values[field], + to: new_value + } + end + end + + def group_scores_for_log(group) + group.group_scores.includes(:team).reload + end + def push_sync_if_needed!(tournament) TournamentSyncEnqueue.call(tournament) end diff --git a/app/controllers/matches_controller.rb b/app/controllers/matches_controller.rb index 53ae3f6..17d8a82 100644 --- a/app/controllers/matches_controller.rb +++ b/app/controllers/matches_controller.rb @@ -7,9 +7,11 @@ class MatchesController < ApplicationController before_action :set_swap_target, only: %i[swap] before_action -> { require_owner! @match.owner }, only: %i[update swap] before_action -> { require_owner! @group.owner }, only: %i[create] - before_action :set_tournament, only: %i[index] + before_action :set_tournament, only: %i[index metadata] + before_action -> { require_owner! @tournament.owner }, only: %i[metadata] before_action -> { require_writable_tournament!(@match.tournament) }, only: %i[update swap] before_action -> { require_writable_tournament!(@group.tournament) }, only: %i[create] + before_action -> { require_writable_tournament!(@tournament) }, only: %i[metadata] # GET/tournaments/1/matches def index @@ -23,49 +25,74 @@ class MatchesController < ApplicationController # POST /groups/:group_id/matches def create - match = build_decider_match + match = nil + synced_tournament = nil - if match.errors.any? - render json: match.errors, status: :unprocessable_entity - elsif match.save - UpdateGroupsGroupScoresAndSave.call(group: @group) - push_sync_if_needed!(@group.tournament) - TournamentLiveBroadcast.call(@group.tournament.id) - render json: match, include: %w[match_scores.team group], status: :created - else - render json: match.errors, status: :unprocessable_entity + Match.transaction do + TournamentTransactionLogger.lock_tournament!(@group.tournament) + match = build_decider_match + + if match.errors.any? + render json: match.errors, status: :unprocessable_content + raise ActiveRecord::Rollback + elsif match.save + UpdateGroupsGroupScoresAndSave.call(group: @group) + group_scores = group_scores_for_log(@group) + TournamentTransactionLogger.record_decider_match_created!(match: match, user: current_user, + group_scores: group_scores) + synced_tournament = @group.tournament + render json: match, include: %w[match_scores.team group], status: :created + else + render json: match.errors, status: :unprocessable_content + raise ActiveRecord::Rollback + end end + + push_sync_if_needed!(synced_tournament) unless synced_tournament.nil? + TournamentLiveBroadcast.call(synced_tournament.id) unless synced_tournament.nil? end # PATCH/PUT /matches/1 def update new_state = match_params['state'] synced_tournament = nil + @transaction_log_group_scores = [] + @transaction_log_advanced_match_ids = [] Match.transaction do - if @match.update(match_params) - handle_match_end if new_state == 'finished' - if @match.group_match? && new_state == 'in_progress' - group = @match.group - logger.warn "Updating groups group score failed for #{group}" unless UpdateGroupsGroupScoresAndSave.call(group:).success? - end - - render json: @match - synced_tournament = @match.tournament - else - render json: @match.errors, status: :unprocessable_entity - raise ActiveRecord::Rollback - end + synced_tournament = update_match_inside_transaction(new_state) end push_sync_if_needed!(synced_tournament) unless synced_tournament.nil? TournamentLiveBroadcast.call(synced_tournament.id) unless synced_tournament.nil? end + # PATCH /tournaments/1/matches/metadata + def metadata + attrs = match_metadata_params + if attrs.empty? + render json: { error: 'At least one metadata field is required' }, status: :bad_request + return + end + + matches = bulk_metadata_matches + return if performed? + + Match.transaction do + TournamentTransactionLogger.lock_tournament!(@tournament) + @tournament.reload + matches.each { |match| match.update!(attrs) } + end + + render json: matches.map(&:reload), each_serializer: LiveMatchSerializer, include: %w[match_scores.team] + push_sync_if_needed!(@tournament) + TournamentLiveBroadcast.call(@tournament.id) + end + # PATCH /matches/1/swap def swap unless @match.swappable_with?(@swap_target) render json: { error: 'Matches must be different group matches from the same group' }, - status: :unprocessable_entity + status: :unprocessable_content return end @@ -93,35 +120,96 @@ class MatchesController < ApplicationController def handle_match_end if @match.group_match? group = @match.group - logger.warn "Updating groups group score failed for #{group}" unless UpdateGroupsGroupScoresAndSave.call(group:).success? + logger.warn "Updating groups group score failed for #{group}" unless UpdateGroupsGroupScoresAndSave.call(group: group).success? + @transaction_log_group_scores = group_scores_for_log(group) + return end - return if @match.group_match? - if @match.winner.nil? render json: { error: 'Stopping undecided Matches isn\'t allowed in playoff stage' }, - status: :unprocessable_entity + status: :unprocessable_content raise ActiveRecord::Rollback end - return if PopulateMatchBelowAndSave.call(match: @match).success? + result = PopulateMatchBelowAndSave.call(match: @match) + if result.success? + @transaction_log_advanced_match_ids = advanced_match_ids_for(result) + return + end render json: { error: 'Moving Team one stage down failed' }, - status: :unprocessable_entity + status: :unprocessable_content raise ActiveRecord::Rollback end - def validate_params - case match_params['state'] - when 'in_progress' - render json: { error: 'Match can\'t start in this state' }, status: :unprocessable_entity unless @match.not_started? - when 'finished' - render json: { error: 'Match can\'t finish in this state' }, status: :unprocessable_entity unless @match.in_progress? - else - render json: { error: 'Invalid target state' }, status: :unprocessable_entity + 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 + return unless params.key?(:state) + + transition_error = match_transition_error(@match, match_params['state']) + return if transition_error.nil? + + render json: { error: transition_error }, status: :unprocessable_content + end + def set_match @match = Match.find(params[:id]) end @@ -142,13 +230,52 @@ class MatchesController < ApplicationController end def match_params - params.slice(:state).permit! + params.slice(:state, :location, :start_time, :notes).permit! end def match_index_params params.slice(:state, :stage_id, :stage_level, :position, :group_stage_position).permit! end + def match_metadata_params + params.slice(:location, :start_time, :notes).permit! + end + + def bulk_metadata_matches + return bulk_metadata_matches_by_id if params.key?(:match_ids) + + return @tournament.matches if ActiveModel::Type::Boolean.new.cast(params[:all]) + return filtered_bulk_metadata_matches if match_index_params.values.any?(&:present?) + + render json: { error: 'Select matches with match_ids, filters, or all=true' }, status: :bad_request + [] + end + + def bulk_metadata_matches_by_id + ids = parse_match_ids + return [] if performed? + + matches = @tournament.matches.select { |match| ids.include?(match.id) } + missing_ids = ids - matches.map(&:id) + if missing_ids.any? + render json: { error: "Matches not found in tournament: #{missing_ids.join(', ')}" }, status: :not_found + return [] + end + + ids.map { |id| matches.find { |match| match.id == id } } + end + + def filtered_bulk_metadata_matches + TournamentMatchesPayload.matches_for(@tournament, match_index_params) + end + + def parse_match_ids + Array(params[:match_ids]).map { |id| Integer(id.to_s, 10) }.uniq + rescue ArgumentError, TypeError + render json: { error: 'match_ids must contain only integer ids' }, status: :bad_request + [] + end + def create_match_params params.slice(:team_ids).permit(team_ids: []) end @@ -162,12 +289,33 @@ class MatchesController < ApplicationController state: :not_started, position: (@group.matches.maximum(:position) || -1) + 1, decider: true, - match_scores: teams.map { |team| MatchScore.new(team:) } + match_scores: teams.map { |team| MatchScore.new(team: team) } ).tap do |match| match.errors.add(:team_ids, 'must contain exactly two teams from this group') if team_ids.size != 2 || teams.size != 2 end end + def match_transition_error(match, new_state) + case new_state + when 'in_progress' + 'Match can\'t start in this state' unless match.not_started? + when 'finished' + 'Match can\'t finish in this state' unless match.in_progress? + when 'not_started' + 'Match can\'t stop in this state' unless match.in_progress? + else + 'Invalid target state' + end + end + + def group_scores_for_log(group) + group.group_scores.includes(:team).reload + end + + def advanced_match_ids_for(result) + Array(result.object_to_save).flatten.grep(Match).reject { |match| match.id == @match.id }.map(&:id) + end + def push_sync_if_needed!(tournament) TournamentSyncEnqueue.call(tournament) end diff --git a/app/controllers/metrics_controller.rb b/app/controllers/metrics_controller.rb new file mode 100644 index 0000000..378fed3 --- /dev/null +++ b/app/controllers/metrics_controller.rb @@ -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 diff --git a/app/controllers/stages_controller.rb b/app/controllers/stages_controller.rb index fe8a386..194bbc0 100644 --- a/app/controllers/stages_controller.rb +++ b/app/controllers/stages_controller.rb @@ -13,66 +13,140 @@ class StagesController < ApplicationController # PUT /stages/1 def update - if stage_params[:state] == 'finished' - unless @stage.state == 'in_progress' - render json: { error: 'Only running group stages can be finished' }, status: :unprocessable_entity - return - end + return render_invalid_stage_state unless stage_params[:state] == 'finished' + return render_stage_not_running unless @stage.in_progress? - synced_tournament = nil - Stage.transaction do - if @stage.update(stage_params) - handle_group_stage_end - - render json: @stage - synced_tournament = @stage.tournament - else - render json: @stage.errors, status: :unprocessable_entity - raise ActiveRecord::Rollback - end - end - push_sync_if_needed!(synced_tournament) unless synced_tournament.nil? - TournamentLiveBroadcast.call(synced_tournament.id) unless synced_tournament.nil? - else - render json: { - error: 'The state attribute may only be changed to finished' - }, status: :unprocessable_entity - end + finish_group_stage end private - def handle_group_stage_end - unless @stage.over? - render json: { - error: 'Group Stage still has some matches that are not over yet. Finish them to generate playoffs' - }, status: :unprocessable_entity + def finish_group_stage + synced_tournament = nil + @playoff_start_failure = nil + @transaction_log_advancing_teams = [] + + Stage.transaction do + synced_tournament = finish_group_stage_inside_transaction + end + record_playoff_start_failure if @playoff_start_failure + push_sync_if_needed!(synced_tournament) unless synced_tournament.nil? + TournamentLiveBroadcast.call(synced_tournament.id) unless synced_tournament.nil? + end + + def finish_group_stage_inside_transaction + TournamentTransactionLogger.lock_tournament!(@stage.tournament) + @stage.reload + return unless stage_running_for_playoff_start? + return unless group_stage_over_for_playoff_start? + return unless recalculate_group_scores_for_playoff_start + return unless no_blocking_ties_for_playoff_start? + + start_playoffs_inside_transaction + end + + def start_playoffs_inside_transaction + @transaction_log_advancing_teams = GroupStageService.get_advancing_teams(@stage) + if @stage.update(stage_params) && + AddPlayoffsToTournamentAndSave.call(tournament: @stage.tournament, + teams: @transaction_log_advancing_teams).success? + record_playoffs_started + render json: @stage + return @stage.tournament + elsif @stage.errors.any? + render json: @stage.errors, status: :unprocessable_content raise ActiveRecord::Rollback end - @stage.groups.each do |group| - next if UpdateGroupsGroupScoresAndSave.call(group:).success? - - render json: { error: 'Updating group scores failed' }, status: :unprocessable_entity - raise ActiveRecord::Rollback - end - - blocking_ties = GroupStageService.blocking_ties_for(@stage) - if blocking_ties.any? - render json: { - error: 'Playoffs cannot be generated yet because a decider match is required for an advancing place', - blocking_ties: - }, status: :unprocessable_entity - raise ActiveRecord::Rollback - end - - return if AddPlayoffsToTournamentAndSave.call(tournament: @stage.tournament, - teams: GroupStageService.get_advancing_teams(@stage)).success? - - render json: { error: 'Generating group stage failed' }, status: :unprocessable_entity + @playoff_start_failure = { reason: 'generation_failed', blocking_ties: [] } + render json: { error: 'Generating group stage failed' }, status: :unprocessable_content raise ActiveRecord::Rollback end + def render_invalid_stage_state + render json: { + error: 'The state attribute may only be changed to finished' + }, status: :unprocessable_content + end + + def render_stage_not_running + render json: { error: 'Only running group stages can be finished' }, status: :unprocessable_content + end + + def stage_running_for_playoff_start? + return true if @stage.in_progress? + + render_stage_not_running + false + end + + def group_stage_over_for_playoff_start? + return true if @stage.over? + + render_playoff_start_failure(reason: 'unfinished_matches') + false + end + + def recalculate_group_scores_for_playoff_start + @stage.groups.each do |group| + next if UpdateGroupsGroupScoresAndSave.call(group: group).success? + + render json: { error: 'Updating group scores failed' }, status: :unprocessable_content + raise ActiveRecord::Rollback + end + + true + end + + def no_blocking_ties_for_playoff_start? + blocking_ties = GroupStageService.blocking_ties_for(@stage) + return true if blocking_ties.empty? + + render_playoff_start_failure(reason: 'decider_missing', blocking_ties: blocking_ties) + false + end + + def render_playoff_start_failure(reason:, blocking_ties: []) + TournamentTransactionLogger.record_playoffs_start_failed!( + stage: @stage, + user: current_user, + reason: reason, + blocking_ties: blocking_ties + ) + render json: playoff_start_failure_payload(reason, blocking_ties), status: :unprocessable_content + nil + end + + def playoff_start_failure_payload(reason, blocking_ties) + if reason == 'unfinished_matches' + return { + error: 'Group Stage still has some matches that are not over yet. Finish them to generate playoffs' + } + end + + { + error: 'Playoffs cannot be generated yet because a decider match is required for an advancing place', + blocking_ties: blocking_ties + } + end + + def record_playoffs_started + TournamentTransactionLogger.record_playoffs_started!( + stage: @stage, + user: current_user, + advancing_teams: @transaction_log_advancing_teams + ) + end + + def record_playoff_start_failure + TournamentTransactionLogger.record_playoffs_start_failed!( + stage: @stage, + user: current_user, + reason: @playoff_start_failure.fetch(:reason), + blocking_ties: @playoff_start_failure.fetch(:blocking_ties) + ) + end + def set_stage @stage = Stage.find(params[:id]) end diff --git a/app/controllers/team_action_items_controller.rb b/app/controllers/team_action_items_controller.rb index a63fd07..1ea7523 100644 --- a/app/controllers/team_action_items_controller.rb +++ b/app/controllers/team_action_items_controller.rb @@ -15,7 +15,7 @@ class TeamActionItemsController < ApplicationController ) render json: @team_action_item else - render json: @team_action_item.errors, status: :unprocessable_entity + render json: @team_action_item.errors, status: :unprocessable_content end end diff --git a/app/controllers/team_action_lists_controller.rb b/app/controllers/team_action_lists_controller.rb index 9938fa9..e355219 100644 --- a/app/controllers/team_action_lists_controller.rb +++ b/app/controllers/team_action_lists_controller.rb @@ -20,9 +20,9 @@ class TeamActionListsController < ApplicationController render json: team_action_list, status: :created rescue TeamActionListService::InvalidSourceError, TeamActionListService::EmptySourceError => e - render json: { error: e.message }, status: :unprocessable_entity + render json: { error: e.message }, status: :unprocessable_content rescue ActiveRecord::RecordInvalid => e - render json: e.record.errors, status: :unprocessable_entity + render json: e.record.errors, status: :unprocessable_content end # PATCH /tournaments/:tournament_id/team_action_lists/:key/teams/:team_id @@ -33,7 +33,7 @@ class TeamActionListsController < ApplicationController ) render json: @team_action_item else - render json: @team_action_item.errors, status: :unprocessable_entity + render json: @team_action_item.errors, status: :unprocessable_content end end diff --git a/app/controllers/teams_controller.rb b/app/controllers/teams_controller.rb index 7b5d7b5..d6e2808 100644 --- a/app/controllers/teams_controller.rb +++ b/app/controllers/teams_controller.rb @@ -3,7 +3,7 @@ class TeamsController < ApplicationController before_action :set_team, only: %i[show update] before_action :authenticate_user!, only: %i[update] - before_action -> { require_owner! @team.owner }, only: %i[update] + before_action :require_team_update_actor!, only: %i[update] before_action -> { require_writable_tournament!(@team.tournament) }, only: %i[update] # GET /teams/1 @@ -13,12 +13,19 @@ class TeamsController < ApplicationController # PATCH/PUT /teams/1 def update - if @team.update(team_params) - push_sync_if_needed!(@team.tournament) - render json: @team - else - render json: @team.errors, status: :unprocessable_entity + Team.transaction do + @team.assign_attributes(team_params) + assign_players_from_params if player_assignment_requested? + raise ActiveRecord::Rollback if performed? + + @team.save! end + return if performed? + + push_sync_if_needed!(@team.tournament) + render json: @team + rescue ActiveRecord::RecordInvalid => e + render json: e.record.errors, status: :unprocessable_content end private @@ -31,6 +38,81 @@ class TeamsController < ApplicationController params.slice(:name).permit! end + def player_assignment_requested? + params.key?(:player_email) || params.key?(:player_id) || + params.key?(:player_emails) || params.key?(:player_ids) + end + + def assign_players_from_params + unless tournament_owner? + render_forbidden_error + return + end + + if params.key?(:player_emails) + @team.players = players_from_emails(params[:player_emails]) + elsif params.key?(:player_ids) + @team.players = players_from_ids(params[:player_ids]) + elsif params.key?(:player_email) + add_or_clear_player_from_email + else + add_or_clear_player_from_id + end + rescue ActiveRecord::RecordInvalid => e + render json: e.record.errors, status: :unprocessable_content + end + + def players_from_emails(emails) + email_values(emails).map { |email| User.find_or_create_player_by_email!(email) } + end + + def players_from_ids(player_ids) + id_values = Array.wrap(player_ids).map(&:to_s).map(&:strip).reject(&:blank?) + return [] if id_values.empty? + + User.where(id: id_values).tap do |players| + raise ActiveRecord::RecordNotFound.new('Could not find all players', 'User') unless players.size == id_values.uniq.size + end + end + + def add_or_clear_player_from_email + email = params[:player_email].to_s.strip + if email.blank? + @team.players = [] + return + end + + add_player(User.find_or_create_player_by_email!(email)) + end + + def add_or_clear_player_from_id + player_id = params[:player_id].to_s.strip + if player_id.blank? + @team.players = [] + return + end + + add_player(User.find(player_id)) + end + + def add_player(player) + @team.players << player unless @team.player_ids.include?(player.id) + end + + def email_values(emails) + Array.wrap(emails).map(&:to_s).map(&:strip).reject(&:blank?) + end + + def require_team_update_actor! + return if tournament_owner? || @team.players.include?(current_user) + + render_forbidden_error + end + + def tournament_owner? + @team.owner == current_user + end + def push_sync_if_needed!(tournament) TournamentSyncEnqueue.call(tournament) end diff --git a/app/controllers/tournament_transaction_log_entries_controller.rb b/app/controllers/tournament_transaction_log_entries_controller.rb new file mode 100644 index 0000000..d860396 --- /dev/null +++ b/app/controllers/tournament_transaction_log_entries_controller.rb @@ -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 diff --git a/app/controllers/tournaments_controller.rb b/app/controllers/tournaments_controller.rb index c459ced..8bbebcf 100644 --- a/app/controllers/tournaments_controller.rb +++ b/app/controllers/tournaments_controller.rb @@ -12,15 +12,19 @@ class TournamentsController < ApplicationController end before_action :set_tournament_for_show, only: %i[show] - before_action :set_tournament, only: %i[update destroy set_timer timer sync_state test_sync] - before_action :authenticate_user!, only: %i[create update destroy set_timer test_sync] - before_action -> { require_owner! @tournament.owner }, only: %i[update destroy set_timer test_sync] + before_action :set_tournament, + only: %i[update destroy destroy_playoffs set_timer timer sync_state test_sync start_next_group_matches] + before_action :authenticate_user!, + only: %i[create update destroy destroy_playoffs set_timer test_sync start_next_group_matches] + before_action -> { require_owner! @tournament.owner }, + only: %i[update destroy destroy_playoffs set_timer test_sync start_next_group_matches] before_action :validate_create_params, only: %i[create] before_action :validate_update_params, only: %i[update] before_action :validate_set_timer_params, only: %i[set_timer] before_action :ensure_update_allowed!, only: %i[update] before_action :ensure_destroy_allowed!, only: %i[destroy] before_action :ensure_timer_update_allowed!, only: %i[set_timer] + before_action -> { require_writable_tournament!(@tournament) }, only: %i[destroy_playoffs start_next_group_matches] before_action :authorize_sync_request!, only: %i[sync_state] rescue_from ActiveRecord::RecordNotFound, with: :render_not_found_error @@ -62,9 +66,10 @@ class TournamentsController < ApplicationController if tournament.read_only_mode? if tournament.save + TournamentTransactionLogger.record_tournament_created!(tournament: tournament, user: current_user) render json: tournament, status: :created, location: tournament else - render json: tournament.errors, status: :unprocessable_entity + render json: tournament.errors, status: :unprocessable_content end return end @@ -75,7 +80,7 @@ class TournamentsController < ApplicationController params.require(:playoff_teams_amount) groups = organize_teams_in_groups(teams) # add groups to tournament - result = AddGroupStageToTournamentAndSave.call(tournament:, groups:) + result = AddGroupStageToTournamentAndSave.call(tournament: tournament, groups: groups) # associate provided teams with tournament on success tournament.teams = groups.flatten if result.success? else @@ -84,18 +89,19 @@ class TournamentsController < ApplicationController # associate provided teams with tournament tournament.teams = teams # add playoff stage to tournament - result = AddPlayoffsToTournamentAndSave.call(tournament:, teams: tournament.teams) + result = AddPlayoffsToTournamentAndSave.call(tournament: tournament, teams: tournament.teams) end # validate tournament unless tournament.valid? - render json: tournament.errors, status: :unprocessable_entity + render json: tournament.errors, status: :unprocessable_content return end # return appropriate result if result.success? + TournamentTransactionLogger.record_tournament_created!(tournament: result.tournament, user: current_user) render json: result.tournament, status: :created, location: result.tournament else - render json: { error: 'Tournament generation failed' }, status: :unprocessable_entity + render json: { error: 'Tournament generation failed' }, status: :unprocessable_content end end @@ -114,7 +120,7 @@ class TournamentsController < ApplicationController synced_tournament = @tournament render json: @tournament else - render json: @tournament.errors, status: :unprocessable_entity + render json: @tournament.errors, status: :unprocessable_content raise ActiveRecord::Rollback end end @@ -126,6 +132,30 @@ class TournamentsController < ApplicationController @tournament.destroy end + # DELETE /tournaments/:id/playoffs + def destroy_playoffs + synced_tournament = nil + + Tournament.transaction do + TournamentTransactionLogger.lock_tournament!(@tournament) + @tournament.reload + + result = TournamentPlayoffDeletion.call(tournament: @tournament) + unless result.success? + render json: { error: result.error }, status: :unprocessable_content + raise ActiveRecord::Rollback + end + + synced_tournament = @tournament.reload + render json: playoff_deletion_payload(synced_tournament) + end + + return if synced_tournament.nil? + + push_sync_if_needed!(synced_tournament) + TournamentLiveBroadcast.call(synced_tournament.id) + end + # GET /tournaments/:id/timer def timer render json: serialize_timer_state(@tournament) @@ -133,12 +163,29 @@ class TournamentsController < ApplicationController # PATCH /tournaments/:id/timer def set_timer - if @tournament.update(timer_params) - push_sync_if_needed!(@tournament) - render json: @tournament - else - render json: @tournament.errors, status: :unprocessable_entity + synced_tournament = nil + + Tournament.transaction do + TournamentTransactionLogger.lock_tournament!(@tournament) + @tournament.reload + old_timer = serialize_timer_state(@tournament) + + if @tournament.update(timer_params) + TournamentTransactionLogger.record_timer_changed!( + tournament: @tournament, + user: current_user, + old_timer: old_timer, + new_timer: serialize_timer_state(@tournament) + ) + synced_tournament = @tournament + render json: @tournament + else + render json: @tournament.errors, status: :unprocessable_content + raise ActiveRecord::Rollback + end end + + push_sync_if_needed!(synced_tournament) unless synced_tournament.nil? end def sync_state @@ -146,14 +193,14 @@ class TournamentsController < ApplicationController TournamentLiveBroadcast.call(@tournament.id) render json: @tournament, include: '**' rescue KeyError, ArgumentError => e - render json: { error: e.message }, status: :unprocessable_entity + render json: { error: e.message }, status: :unprocessable_content end def test_sync unless @tournament.sync_push_enabled? return render json: { error: 'Tournament sync test requires writable tournament with sync_target_url and sync_auth_token' - }, status: :unprocessable_entity + }, status: :unprocessable_content end TournamentSyncPusher.push!(@tournament) @@ -166,19 +213,52 @@ class TournamentsController < ApplicationController }, status: :bad_gateway end + # PATCH /tournaments/:id/start_next_group_matches + def start_next_group_matches + started_matches = TournamentGroupMatchStarter.start_next!(@tournament, logger:, user: current_user) + if started_matches.any? + push_sync_if_needed!(@tournament) + TournamentLiveBroadcast.call(@tournament.id) + end + + render json: ActiveModelSerializers::SerializableResource.new( + started_matches, + each_serializer: LiveMatchSerializer, + include: %w[match_scores.team] + ).as_json + end + private def timer_params - { - timestamp: params[:timestamp], - timer_mode: params[:timer_mode] + timer_params = { + timer_reason: params[:timer_reason], + timer_reason_text: params[:timer_reason_text] } + timer_params[:timestamp] = params[:timestamp] if params.key?(:timestamp) + timer_params[:timer_mode] = params[:timer_mode] if params.key?(:timer_mode) + timer_params end def serialize_timer_state(tournament) { timestamp: tournament.timestamp&.iso8601, - timer_mode: tournament.timer_mode + timer_mode: tournament.timer_mode, + timer_reason: tournament.timer_reason, + timer_reason_text: tournament.timer_reason_text + } + end + + def playoff_deletion_payload(tournament) + { + id: tournament.id, + stages: tournament.stages.sort_by(&:level).map do |stage| + { + id: stage.id, + level: stage.level, + state: stage.state + } + end } end @@ -196,7 +276,14 @@ class TournamentsController < ApplicationController if team[:id] Team.find team[:id] elsif team[:name] - Team.create name: team[:name] + Team.create(name: team[:name], players: players_from_team_params(team)) + end + end + + def players_from_team_params(team) + player_emails = team[:player_emails].presence || team[:player_email] + Array.wrap(player_emails).map(&:to_s).map(&:strip).reject(&:blank?).map do |player_email| + User.find_or_create_player_by_email!(player_email) end end @@ -209,7 +296,7 @@ class TournamentsController < ApplicationController @tournament = profiling.measure('load_tournament') do Tournament.includes( :user, - :teams, + { teams: :players }, stages: [ { matches: { match_scores: :team } }, { groups: [ @@ -245,6 +332,7 @@ class TournamentsController < ApplicationController params.slice( :name, :description, :public, :teams, :group_stage, :playoff_teams_amount, :instant_finalists_amount, :intermediate_round_participants_amount, + :location, :starts_at, :ends_at, :website_url, :read_only_mode, :sync_target_url, :sync_auth_token ).permit! end @@ -253,9 +341,9 @@ class TournamentsController < ApplicationController return if ActiveModel::Type::Boolean.new.cast(params[:read_only_mode]) teams = params['teams'] - return if teams.is_a?(Array) && teams.count { |t| !(t.is_a? ActionController::Parameters) }.zero? + return if teams.is_a?(Array) && teams.count { |team| !team.is_a?(ActionController::Parameters) }.zero? - render json: { error: 'Invalid teams array' }, status: :unprocessable_entity + render json: { error: 'Invalid teams array' }, status: :unprocessable_content end def only_playoff_teams_amount_changed @@ -286,7 +374,7 @@ class TournamentsController < ApplicationController render json: { error: 'playoff_teams_amount, instant_finalists_amount and intermediate_round_participants_amount don\'t match' - }, status: :unprocessable_entity + }, status: :unprocessable_content end def sync_state_params @@ -296,15 +384,13 @@ class TournamentsController < ApplicationController def authorize_sync_request! unless @tournament.sync_accepts_push? return render json: { error: 'Tournament is not configured to accept sync pushes' }, - status: :unprocessable_entity + status: :unprocessable_content end token = request.authorization.to_s.delete_prefix('Bearer ').presence || request.headers['X-Tournament-Sync-Token'].to_s return render json: { error: 'Missing sync token' }, status: :unauthorized if token.blank? - matches = token.bytesize == @tournament.sync_auth_token.to_s.bytesize && - ActiveSupport::SecurityUtils.secure_compare(token, @tournament.sync_auth_token.to_s) - return if matches + return if @tournament.sync_token_matches?(token) render json: { error: 'Invalid sync token' }, status: :unauthorized end @@ -337,72 +423,125 @@ class TournamentsController < ApplicationController timestamp = params[:timestamp] timestamp_seconds = params[:timestamp_seconds] timer_mode = params[:timer_mode] + timer_reason_param_present = params.key?(:timer_reason) + timer_reason_text_param_present = params.key?(:timer_reason_text) + timer_reason_update_requested = timer_reason_param_present || timer_reason_text_param_present + normalize_timer_reason_params + timer_reason = params[:timer_reason] + timer_reason_text = params[:timer_reason_text] clear_timer = ActiveModel::Type::Boolean.new.cast(params[:clear_timer]) - if clear_timer - if timestamp.present? || timestamp_seconds.present? || timer_mode.present? - return render json: { error: 'clear_timer cannot be combined with timestamp values or timer_mode' }, - status: :unprocessable_entity - end + return validate_clear_timer_params(timestamp, timestamp_seconds, timer_mode, timer_reason, timer_reason_text) if clear_timer - params[:timestamp] = nil - params[:timer_mode] = nil - return + return render_timer_validation_error('Invalid timer_mode') if timer_mode.present? && !Tournament::TIMER_MODES.include?(timer_mode) + + timer_reason_error = validate_timer_reason_params( + timer_reason, + timer_reason_text, + timer_reason_param_present, + timer_reason_text_param_present + ) + return render_timer_validation_error(timer_reason_error) if timer_reason_error + + return if validate_timer_timestamp_params(timestamp, timestamp_seconds, timer_mode) + + if timer_mode == 'countup' + params[:timestamp] = Time.zone.now + elsif timer_mode.present? + render_timer_validation_error('timer_mode requires timestamp, timestamp_seconds, or clear_timer') + elsif timer_reason_update_requested + render_timer_validation_error('timer_reason requires active timer') unless timer_active? + else + render_timer_validation_error('timestamp, timestamp_seconds, or clear_timer is required') end + end - if timer_mode.present? && %w[countdown countup].exclude?(timer_mode) - return render json: { error: 'Invalid timer_mode' }, status: :unprocessable_entity - end - - # throw error if both timestamp and timestamp_seconds are present + def validate_timer_timestamp_params(timestamp, timestamp_seconds, timer_mode) if timestamp.present? && timestamp_seconds.present? - return render json: { error: 'Only one of timestamp or timestamp_seconds is allowed' }, - status: :unprocessable_entity + return render_timer_validation_error('Only one of timestamp or timestamp_seconds is allowed') end if timer_mode == 'countup' && timestamp_seconds.present? - return render json: { error: 'timestamp_seconds is only allowed for countdown mode' }, - status: :unprocessable_entity + return render_timer_validation_error('timestamp_seconds is only allowed for countdown mode') end - if timestamp_seconds.present? - begin - timestamp_seconds = Integer(timestamp_seconds) - rescue ArgumentError - return render json: { error: 'Invalid seconds format' }, status: :unprocessable_entity - end + return validate_timestamp_seconds_param(timestamp_seconds) if timestamp_seconds.present? + return validate_timestamp_param(timestamp, timer_mode) if timestamp.present? - if timestamp_seconds <= 0 - return render json: { error: 'Timestamp must be in the future for countdown mode' }, - status: :unprocessable_entity - end + false + end - parsed_time = Time.zone.now + timestamp_seconds - params[:timestamp] = parsed_time - params[:timer_mode] = 'countdown' - elsif timestamp.present? - begin - parsed_time = Time.zone.parse(timestamp) - if parsed_time.nil? - return render json: { error: 'Invalid timestamp format' }, status: :unprocessable_entity - elsif timer_mode != 'countup' && !parsed_time.future? - return render json: { error: 'Timestamp must be in the future for countdown mode' }, - status: :unprocessable_entity - end + def validate_timestamp_seconds_param(timestamp_seconds) + seconds = Integer(timestamp_seconds) + return render_timer_validation_error('Timestamp must be in the future for countdown mode') if seconds <= 0 - params[:timestamp] = parsed_time - params[:timer_mode] = timer_mode.presence || 'countdown' - rescue ArgumentError - render json: { error: 'Invalid timestamp format' }, status: :unprocessable_entity - end - elsif timer_mode == 'countup' - params[:timestamp] = Time.zone.now - elsif timer_mode.present? - render json: { error: 'timer_mode requires timestamp, timestamp_seconds, or clear_timer' }, - status: :unprocessable_entity - else - render json: { error: 'timestamp, timestamp_seconds, or clear_timer is required' }, - status: :unprocessable_entity + params[:timestamp] = Time.zone.now + seconds + params[:timer_mode] = 'countdown' + rescue ArgumentError + render_timer_validation_error('Invalid seconds format') + end + + def validate_timestamp_param(timestamp, timer_mode) + parsed_time = Time.zone.parse(timestamp) + if parsed_time.nil? + return render_timer_validation_error('Invalid timestamp format') + elsif timer_mode != 'countup' && !parsed_time.future? + return render_timer_validation_error('Timestamp must be in the future for countdown mode') end + + params[:timestamp] = parsed_time + params[:timer_mode] = timer_mode.presence || 'countdown' + rescue ArgumentError + render_timer_validation_error('Invalid timestamp format') + end + + def normalize_timer_reason_params + params[:timer_reason] = params[:timer_reason].presence + params[:timer_reason_text] = timer_reason_text_param + end + + def timer_reason_text_param + return nil unless params.key?(:timer_reason_text) + + params[:timer_reason_text].to_s.strip.presence + end + + def validate_clear_timer_params(timestamp, timestamp_seconds, timer_mode, timer_reason, timer_reason_text) + if timestamp.present? || timestamp_seconds.present? || timer_mode.present? || + timer_reason.present? || timer_reason_text.present? + return render_timer_validation_error('clear_timer cannot be combined with timer values or timer reason') + end + + params[:timestamp] = nil + params[:timer_mode] = nil + params[:timer_reason] = nil + params[:timer_reason_text] = nil + end + + def validate_timer_reason_params( + timer_reason, + timer_reason_text, + timer_reason_param_present, + timer_reason_text_param_present + ) + return 'timer_reason is required when timer_reason_text is provided' if timer_reason_text_param_present && !timer_reason_param_present + return if timer_reason.blank? && timer_reason_text.blank? + + return 'timer_reason_text is only allowed when timer_reason is custom' if timer_reason.blank? + return 'Invalid timer_reason' unless Tournament::TIMER_REASONS.include?(timer_reason) + + return 'timer_reason_text is required for custom timer_reason' if timer_reason == 'custom' && timer_reason_text.blank? + + return unless timer_reason != 'custom' && timer_reason_text.present? + + 'timer_reason_text is only allowed when timer_reason is custom' + end + + def timer_active? + @tournament.timestamp.present? && @tournament.timer_mode.present? + end + + def render_timer_validation_error(message) + render json: { error: message }, status: :unprocessable_content end end diff --git a/app/controllers/version_controller.rb b/app/controllers/version_controller.rb index 83c7bdc..ae01a3f 100644 --- a/app/controllers/version_controller.rb +++ b/app/controllers/version_controller.rb @@ -8,12 +8,6 @@ class VersionController < ApplicationController private def git_commit_sha - ENV['GIT_COMMIT_SHA'].presence || local_git_commit_sha || 'unknown' - end - - def local_git_commit_sha - Rails.root.join('.git').exist? ? `git rev-parse HEAD`.strip.presence : nil - rescue StandardError - nil + ENV['GIT_COMMIT_SHA'].presence || 'aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa' end end diff --git a/app/models/match.rb b/app/models/match.rb index af4adee..de64aec 100644 --- a/app/models/match.rb +++ b/app/models/match.rb @@ -1,8 +1,8 @@ # frozen_string_literal: true class Match < ApplicationRecord - enum state: { single_team: 0, not_ready: 1, not_started: 2, in_progress: 3, finished: 4, - undecided: 5 } + enum :state, { single_team: 0, not_ready: 1, not_started: 2, in_progress: 3, finished: 4, + undecided: 5 } belongs_to :stage, optional: true belongs_to :group, optional: true diff --git a/app/models/stage.rb b/app/models/stage.rb index 52f9d9b..3784d18 100644 --- a/app/models/stage.rb +++ b/app/models/stage.rb @@ -1,7 +1,7 @@ # frozen_string_literal: true class Stage < ApplicationRecord - enum state: { playoff_stage: 0, intermediate_stage: 1, in_progress: 2, finished: 3 } + enum :state, { playoff_stage: 0, intermediate_stage: 1, in_progress: 2, finished: 3 } belongs_to :tournament has_many :matches, dependent: :destroy diff --git a/app/models/team.rb b/app/models/team.rb index 4ad8508..57c2a44 100644 --- a/app/models/team.rb +++ b/app/models/team.rb @@ -2,6 +2,8 @@ class Team < ApplicationRecord belongs_to :tournament, optional: true + has_many :team_players, dependent: :destroy + has_many :players, through: :team_players has_many :group_scores, dependent: :destroy has_many :match_scores, dependent: :destroy has_many :bets, dependent: :destroy @@ -9,5 +11,5 @@ class Team < ApplicationRecord validates :name, presence: true - delegate :owner, to: :tournament + delegate :owner, to: :tournament, allow_nil: true end diff --git a/app/models/team_player.rb b/app/models/team_player.rb new file mode 100644 index 0000000..ada0c3a --- /dev/null +++ b/app/models/team_player.rb @@ -0,0 +1,6 @@ +# frozen_string_literal: true + +class TeamPlayer < ApplicationRecord + belongs_to :team + belongs_to :player, class_name: 'User' +end diff --git a/app/models/tournament.rb b/app/models/tournament.rb index 3debf27..dee1591 100644 --- a/app/models/tournament.rb +++ b/app/models/tournament.rb @@ -1,26 +1,38 @@ # frozen_string_literal: true require 'securerandom' +require 'openssl' class Tournament < ApplicationRecord + TIMER_MODES = %w[countdown countup].freeze + TIMER_REASONS = %w[tournament_start playoff_start match_end custom].freeze + METADATA_STRING_ATTRIBUTES = %i[location website_url].freeze + belongs_to :user + belongs_to :owner, class_name: 'User', foreign_key: :user_id, inverse_of: :tournaments has_many :teams, dependent: :destroy has_many :stages, dependent: :destroy has_many :beamers, dependent: :destroy has_many :team_action_lists, dependent: :destroy + has_many :transaction_log_entries, class_name: 'TournamentTransactionLogEntry', dependent: :destroy validates :name, presence: true validates :code, presence: true, uniqueness: true - validates :timer_mode, inclusion: { in: %w[countdown countup] }, allow_nil: true + validates :timer_mode, inclusion: { in: TIMER_MODES }, allow_nil: true + validates :timer_reason, inclusion: { in: TIMER_REASONS }, allow_nil: true validates :sync_target_url, format: URI::DEFAULT_PARSER.make_regexp(%w[http https]), allow_blank: true + validates :website_url, format: URI::DEFAULT_PARSER.make_regexp(%w[http https]), allow_blank: true validate :sync_configuration_is_complete validate :playoff_teams_amount_is_positive_power_of_two - - alias_attribute :owner, :user + validate :timer_reason_text_matches_reason + validate :ends_at_is_after_starts_at after_initialize :generate_code after_create_commit :ensure_default_beamer! + before_validation :normalize_timer_reason + before_validation :normalize_metadata + before_validation :protect_follower_sync_token before_validation :clear_follower_sync_token_when_disabling_read_only_mode after_commit :broadcast_timer_state_change, if: :saved_change_to_timer_state? @@ -45,7 +57,27 @@ class Tournament < ApplicationRecord end def sync_accepts_push? - read_only_mode? && sync_auth_token.present? + read_only_mode? && sync_auth_configured? + end + + def sync_auth_configured? + sync_auth_token.present? || sync_auth_token_digest.present? + end + + def sync_token_matches?(token) + return false if token.blank? + + if sync_auth_token_digest.present? + expected_digest = self.class.sync_auth_token_digest(token) + return ActiveSupport::SecurityUtils.secure_compare(expected_digest, sync_auth_token_digest) + end + + token.bytesize == sync_auth_token.to_s.bytesize && + ActiveSupport::SecurityUtils.secure_compare(token, sync_auth_token.to_s) + end + + def self.sync_auth_token_digest(token) + OpenSSL::HMAC.hexdigest('SHA256', Rails.application.secret_key_base, token.to_s) end private @@ -75,15 +107,15 @@ class Tournament < ApplicationRecord end def sync_configuration_blank? - sync_target_url.blank? && sync_auth_token.blank? + sync_target_url.blank? && !sync_auth_configured? end def sync_configuration_complete? - sync_target_url.present? && sync_auth_token.present? + sync_target_url.present? && sync_auth_configured? end def follower_sync_token_only? - read_only_mode? && sync_auth_token.present? && sync_target_url.blank? + read_only_mode? && sync_auth_configured? && sync_target_url.blank? end def ensure_default_beamer! @@ -95,13 +127,55 @@ class Tournament < ApplicationRecord def clear_follower_sync_token_when_disabling_read_only_mode return if read_only_mode? + + self.sync_auth_token_digest = nil return if sync_target_url.present? self.sync_auth_token = nil end + def protect_follower_sync_token + return unless read_only_mode? + + if sync_auth_token.present? + self.sync_auth_token_digest = self.class.sync_auth_token_digest(sync_auth_token) + self.sync_auth_token = nil + elsif will_save_change_to_sync_auth_token? && sync_auth_token.blank? + self.sync_auth_token_digest = nil + end + end + + def normalize_timer_reason + self.timer_reason = timer_reason.presence + self.timer_reason_text = timer_reason_text&.strip&.presence + end + + def timer_reason_text_matches_reason + if timer_reason == 'custom' + errors.add(:timer_reason_text, "can't be blank") if timer_reason_text.blank? + elsif timer_reason_text.present? + errors.add(:timer_reason_text, 'is only allowed when timer_reason is custom') + end + end + + def normalize_metadata + METADATA_STRING_ATTRIBUTES.each do |attribute| + self[attribute] = self[attribute]&.strip&.presence + end + end + + def ends_at_is_after_starts_at + return if starts_at.blank? || ends_at.blank? + return if ends_at > starts_at + + errors.add(:ends_at, 'must be after starts_at') + end + def saved_change_to_timer_state? - saved_change_to_timestamp? || saved_change_to_timer_mode? + saved_change_to_timestamp? || + saved_change_to_timer_mode? || + saved_change_to_timer_reason? || + saved_change_to_timer_reason_text? end def broadcast_timer_state_change diff --git a/app/models/tournament_transaction_log_entry.rb b/app/models/tournament_transaction_log_entry.rb new file mode 100644 index 0000000..88128f1 --- /dev/null +++ b/app/models/tournament_transaction_log_entry.rb @@ -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 diff --git a/app/models/user.rb b/app/models/user.rb index 6d2a109..7e156c0 100644 --- a/app/models/user.rb +++ b/app/models/user.rb @@ -11,5 +11,38 @@ class User < ApplicationRecord validates :username, presence: true, uniqueness: { case_sensitive: false } has_many :tournaments, dependent: :destroy + has_many :team_players, foreign_key: :player_id, inverse_of: :player, dependent: :destroy + has_many :player_teams, through: :team_players, source: :team + has_many :played_tournaments, -> { distinct }, through: :player_teams, source: :tournament has_many :bets, dependent: :destroy + + class << self + def find_or_create_player_by_email!(email) + normalized_email = email.to_s.strip.downcase + where('LOWER(email) = ?', normalized_email).first || create!(email: normalized_email) do |user| + generated_password = Devise.friendly_token.first(24) + user.username = unique_player_username(normalized_email) + user.password = generated_password + user.password_confirmation = generated_password + user.confirmed_at = Time.current + user.uid = normalized_email + user.provider = 'email' + end + end + + private + + def unique_player_username(email) + base = email.split('@').first.to_s.parameterize.presence || 'player' + candidate = base + suffix = 2 + + while where('LOWER(username) = ?', candidate.downcase).exists? + candidate = "#{base}-#{suffix}" + suffix += 1 + end + + candidate + end + end end diff --git a/app/serializers/account_profile_serializer.rb b/app/serializers/account_profile_serializer.rb new file mode 100644 index 0000000..de9a2ed --- /dev/null +++ b/app/serializers/account_profile_serializer.rb @@ -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 diff --git a/app/serializers/account_summary_serializer.rb b/app/serializers/account_summary_serializer.rb new file mode 100644 index 0000000..6d2ae03 --- /dev/null +++ b/app/serializers/account_summary_serializer.rb @@ -0,0 +1,9 @@ +# frozen_string_literal: true + +class AccountSummarySerializer < ApplicationSerializer + attributes :name, :username + + def name + object.username + end +end diff --git a/app/serializers/live_match_serializer.rb b/app/serializers/live_match_serializer.rb index 3b0f27a..a91295a 100644 --- a/app/serializers/live_match_serializer.rb +++ b/app/serializers/live_match_serializer.rb @@ -1,7 +1,7 @@ # frozen_string_literal: true class LiveMatchSerializer < ApplicationSerializer - attributes :position, :state, :teams, :group, :stage + attributes :position, :state, :location, :start_time, :notes, :teams, :group, :stage has_many :match_scores diff --git a/app/serializers/match_serializer.rb b/app/serializers/match_serializer.rb index 50feade..3479d5a 100644 --- a/app/serializers/match_serializer.rb +++ b/app/serializers/match_serializer.rb @@ -1,7 +1,7 @@ # frozen_string_literal: true class MatchSerializer < ApplicationSerializer - attributes :state, :position, :winner, :decider + attributes :state, :position, :winner, :decider, :location, :start_time, :notes def winner ActiveModelSerializers::SerializableResource.new(object.winner).as_json diff --git a/app/serializers/simple_tournament_serializer.rb b/app/serializers/simple_tournament_serializer.rb index 0d88639..496cbf4 100644 --- a/app/serializers/simple_tournament_serializer.rb +++ b/app/serializers/simple_tournament_serializer.rb @@ -1,5 +1,13 @@ # frozen_string_literal: true class SimpleTournamentSerializer < ApplicationSerializer - attributes :name, :code, :public + attributes :name, :code, :public, :location, :website_url + + attribute :starts_at do + object.starts_at&.iso8601 + end + + attribute :ends_at do + object.ends_at&.iso8601 + end end diff --git a/app/serializers/team_serializer.rb b/app/serializers/team_serializer.rb index cf33a86..5799663 100644 --- a/app/serializers/team_serializer.rb +++ b/app/serializers/team_serializer.rb @@ -1,5 +1,9 @@ # frozen_string_literal: true class TeamSerializer < ApplicationSerializer - attributes :name + attributes :name, :players + + def players + object.players.map { |player| AccountSummarySerializer.new(player).as_json } + end end diff --git a/app/serializers/tournament_serializer.rb b/app/serializers/tournament_serializer.rb index 8f4f3ae..30c4503 100644 --- a/app/serializers/tournament_serializer.rb +++ b/app/serializers/tournament_serializer.rb @@ -5,6 +5,7 @@ class TournamentSerializer < SimpleTournamentSerializer :instant_finalists_amount, :intermediate_round_participants_amount attribute :read_only_mode, if: :sync_metadata_visible? attribute :sync_target_url, if: :sync_metadata_visible? + attribute :sync_auth_configured, if: :sync_metadata_visible? attribute :sync_last_push_error, if: :sync_metadata_visible? # NEVER expose sync_auth_token anywhere - it should only ever be written to or checked against @@ -16,6 +17,8 @@ class TournamentSerializer < SimpleTournamentSerializer end attribute :timer_mode + attribute :timer_reason + attribute :timer_reason_text attribute :sync_last_pushed_at, if: :sync_metadata_visible? do object.sync_last_pushed_at&.iso8601 @@ -31,6 +34,7 @@ class TournamentSerializer < SimpleTournamentSerializer { id: team.id, name: team.name, + players: team.players.map { |player| AccountSummarySerializer.new(player).as_json }, advancing_from_group_stage: adv_teams.include?(team) } end @@ -39,4 +43,8 @@ class TournamentSerializer < SimpleTournamentSerializer def sync_metadata_visible? scope.present? && scope == object.owner end + + def sync_auth_configured + object.sync_auth_configured? + end end diff --git a/app/serializers/tournament_transaction_log_entry_serializer.rb b/app/serializers/tournament_transaction_log_entry_serializer.rb new file mode 100644 index 0000000..aa8f708 --- /dev/null +++ b/app/serializers/tournament_transaction_log_entry_serializer.rb @@ -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 diff --git a/app/services/metrics_registry.rb b/app/services/metrics_registry.rb new file mode 100644 index 0000000..36d50a9 --- /dev/null +++ b/app/services/metrics_registry.rb @@ -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! diff --git a/app/services/tournament_group_match_starter.rb b/app/services/tournament_group_match_starter.rb new file mode 100644 index 0000000..f11db45 --- /dev/null +++ b/app/services/tournament_group_match_starter.rb @@ -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 diff --git a/app/services/tournament_matches_payload.rb b/app/services/tournament_matches_payload.rb index dea06cc..2a8fedb 100644 --- a/app/services/tournament_matches_payload.rb +++ b/app/services/tournament_matches_payload.rb @@ -79,12 +79,7 @@ class TournamentMatchesPayload end def upcoming_group_matches(tournament) - group_stage = tournament.stages.find { |stage| stage.level == -1 } - return [] if group_stage.nil? - - group_stage.groups.filter_map do |group| - group.matches.select { |match| match.state == 'not_started' }.min_by(&:position) - end + TournamentGroupMatchStarter.upcoming_matches(tournament) end def upcoming_playoff_matches(tournament) diff --git a/app/services/tournament_playoff_deletion.rb b/app/services/tournament_playoff_deletion.rb new file mode 100644 index 0000000..0e3066b --- /dev/null +++ b/app/services/tournament_playoff_deletion.rb @@ -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 diff --git a/app/services/tournament_snapshot_builder.rb b/app/services/tournament_snapshot_builder.rb index 8ca4a26..148c76e 100644 --- a/app/services/tournament_snapshot_builder.rb +++ b/app/services/tournament_snapshot_builder.rb @@ -22,8 +22,12 @@ class TournamentSnapshotBuilder def build payload = tournament.attributes.symbolize_keys.slice(*ROOT_ATTRIBUTES) + payload[:starts_at] = tournament.starts_at&.iso8601 + payload[:ends_at] = tournament.ends_at&.iso8601 payload[:timestamp] = tournament.timestamp&.iso8601 payload[:timer_mode] = tournament.timer_mode + payload[:timer_reason] = tournament.timer_reason + payload[:timer_reason_text] = tournament.timer_reason_text payload[:sync_generated_at] = Time.current.iso8601(6) payload[:teams] = teams_payload payload[:beamers] = beamers_payload diff --git a/app/services/tournament_snapshot_importer.rb b/app/services/tournament_snapshot_importer.rb index 26702e0..898d23a 100644 --- a/app/services/tournament_snapshot_importer.rb +++ b/app/services/tournament_snapshot_importer.rb @@ -6,6 +6,8 @@ class TournamentSnapshotImporter code description public + location + website_url playoff_teams_amount instant_finalists_amount intermediate_round_participants_amount @@ -67,8 +69,12 @@ class TournamentSnapshotImporter def update_tournament! root_attrs = snapshot.slice(*ROOT_ATTRIBUTES) root_attrs[:code] = tournament.code if code_taken_by_another_tournament?(root_attrs[:code]) + root_attrs[:starts_at] = parse_time(snapshot[:starts_at]) + root_attrs[:ends_at] = parse_time(snapshot[:ends_at]) root_attrs[:timestamp] = parse_time(snapshot[:timestamp]) root_attrs[:timer_mode] = snapshot[:timer_mode] + root_attrs[:timer_reason] = snapshot[:timer_reason] + root_attrs[:timer_reason_text] = snapshot[:timer_reason_text] root_attrs[:sync_source_tournament_id] = snapshot[:id] root_attrs[:sync_last_imported_snapshot_at] = snapshot_generated_at tournament.update!(root_attrs) @@ -137,6 +143,9 @@ class TournamentSnapshotImporter match.state = attrs.fetch(:state) match.position = attrs[:position] match.decider = attrs[:decider] + match.location = attrs[:location] + match.start_time = attrs[:start_time] + match.notes = attrs[:notes] end end diff --git a/app/services/tournament_sync_schema.rb b/app/services/tournament_sync_schema.rb index 64b4d33..e49d1b1 100644 --- a/app/services/tournament_sync_schema.rb +++ b/app/services/tournament_sync_schema.rb @@ -9,11 +9,17 @@ class TournamentSyncSchema code description public + location + starts_at + ends_at + website_url playoff_teams_amount instant_finalists_amount intermediate_round_participants_amount timestamp timer_mode + timer_reason + timer_reason_text ].freeze, ignored: %w[ user_id @@ -22,6 +28,7 @@ class TournamentSyncSchema read_only_mode sync_target_url sync_auth_token + sync_auth_token_digest sync_source_tournament_id sync_last_pushed_at sync_last_push_error @@ -45,7 +52,7 @@ class TournamentSyncSchema ignored: %w[created_at updated_at sync_source_id].freeze }.freeze, Match => { - synced: %w[id state position stage_id group_id decider].freeze, + synced: %w[id state position stage_id group_id decider location start_time notes].freeze, ignored: %w[created_at updated_at sync_source_id].freeze }.freeze, MatchScore => { diff --git a/app/services/tournament_timer_broadcast_payload.rb b/app/services/tournament_timer_broadcast_payload.rb index 9b790de..80bdf5c 100644 --- a/app/services/tournament_timer_broadcast_payload.rb +++ b/app/services/tournament_timer_broadcast_payload.rb @@ -7,7 +7,9 @@ class TournamentTimerBroadcastPayload type: 'timer.updated', tournament_id: tournament.id, timestamp: tournament.timestamp&.iso8601, - timer_mode: tournament.timer_mode + timer_mode: tournament.timer_mode, + timer_reason: tournament.timer_reason, + timer_reason_text: tournament.timer_reason_text } end end diff --git a/app/services/tournament_transaction_logger.rb b/app/services/tournament_transaction_logger.rb new file mode 100644 index 0000000..2cd1e43 --- /dev/null +++ b/app/services/tournament_transaction_logger.rb @@ -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 diff --git a/config/application.rb b/config/application.rb index bd16c7a..f45f03b 100644 --- a/config/application.rb +++ b/config/application.rb @@ -36,6 +36,7 @@ module TurniereBackend config.api_only = true config.action_cable.disable_request_forgery_protection = true config.action_cable.allowed_request_origins = [%r{\Ahttps?://.*\z}] + config.active_storage.variant_processor = :disabled config.middleware.insert_before 0, Rack::Cors do allow do @@ -49,8 +50,5 @@ module TurniereBackend config.action_mailer.default_options = { from: 'noreply@turnie.re' } - - # Active Record - config.active_record.legacy_connection_handling = false end end diff --git a/config/database.yml b/config/database.yml index 2e10203..f339e82 100644 --- a/config/database.yml +++ b/config/database.yml @@ -6,7 +6,7 @@ # default: &default adapter: sqlite3 - pool: <%= ENV.fetch("RAILS_MAX_THREADS") { 5 } %> + pool: <%= ENV.fetch("RAILS_DB_POOL", ENV.fetch("RAILS_MAX_THREADS", 5)) %> timeout: 5000 development: @@ -21,10 +21,12 @@ test: database: db/test.sqlite3 production: &production - url: <%= ENV.fetch("POSTGRES_URL", nil) %> + adapter: postgresql + encoding: unicode + url: <%= ENV["DATABASE_URL"] || ENV["POSTGRES_URL"] %> username: <%= ENV.fetch("POSTGRES_USERNAME", nil) %> password: <%= ENV.fetch("POSTGRES_PASSWORD", nil) %> - pool: <%= ENV.fetch("RAILS_MAX_THREADS") { 5 } %> + pool: <%= ENV.fetch("RAILS_DB_POOL", ENV.fetch("RAILS_MAX_THREADS", 5)) %> beta: # just use the production settings diff --git a/config/environments/production.rb b/config/environments/production.rb index 715f35a..fca6957 100644 --- a/config/environments/production.rb +++ b/config/environments/production.rb @@ -79,12 +79,16 @@ Rails.application.configure do # Mail config.action_mailer.raise_delivery_errors = true config.action_mailer.default_url_options = { host: 'api.turnie.re' } - config.action_mailer.delivery_method = :mailgun - config.action_mailer.mailgun_settings = { - api_key: ENV.fetch('MAILGUN_API_KEY'), - domain: ENV.fetch('MAILGUN_DOMAIN'), - api_host: 'api.eu.mailgun.net' # Uncomment this line for EU region domains - } + if ENV['TURNIERE_DISABLE_EMAIL_DELIVERY'].present? + config.action_mailer.perform_deliveries = false + else + config.action_mailer.delivery_method = :mailgun + config.action_mailer.mailgun_settings = { + api_key: ENV.fetch('MAILGUN_API_KEY'), + domain: ENV.fetch('MAILGUN_DOMAIN'), + api_host: 'api.eu.mailgun.net' # Uncomment this line for EU region domains + } + end # Logging to STDOUT (running in docker by default) config.logger = Logger.new($stdout) diff --git a/config/initializers/devise_routes_rails_8_compat.rb b/config/initializers/devise_routes_rails_8_compat.rb new file mode 100644 index 0000000..a7194fc --- /dev/null +++ b/config/initializers/devise_routes_rails_8_compat.rb @@ -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) diff --git a/config/initializers/devise_token_auth.rb b/config/initializers/devise_token_auth.rb index bb64943..f0997ce 100644 --- a/config/initializers/devise_token_auth.rb +++ b/config/initializers/devise_token_auth.rb @@ -48,5 +48,6 @@ DeviseTokenAuth.setup do |config| # do so by enabling this flag. NOTE: This feature is highly experimental! # config.enable_standard_devise_support = false + config.bypass_sign_in = false config.default_confirm_success_url = 'https://turnie.re' end diff --git a/config/puma.rb b/config/puma.rb index d9a94f3..661e38b 100644 --- a/config/puma.rb +++ b/config/puma.rb @@ -6,16 +6,17 @@ # the maximum value specified for Puma. Default is set to 5 threads for minimum # and maximum; this matches the default thread size of Active Record. # -threads_count = ENV.fetch('RAILS_MAX_THREADS', 5) -threads threads_count, threads_count +max_threads_count = ENV.fetch('RAILS_MAX_THREADS', 5) +min_threads_count = ENV.fetch('RAILS_MIN_THREADS', max_threads_count) +threads min_threads_count, max_threads_count # Specifies the `port` that Puma will listen on to receive requests; default is 3000. # -port ENV.fetch('PORT', 3000) +port ENV.fetch('PORT', 3000) # Specifies the `environment` that Puma will run in. # -environment ENV.fetch('RAILS_ENV', 'development') +environment ENV.fetch('RAILS_ENV', 'production') # Specifies the number of `workers` to boot in clustered mode. # Workers are forked webserver processes. If using threads and workers together @@ -23,14 +24,14 @@ environment ENV.fetch('RAILS_ENV', 'development') # Workers do not work on JRuby or Windows (both of which do not support # processes). # -# workers ENV.fetch("WEB_CONCURRENCY") { 2 } +workers ENV.fetch('WEB_CONCURRENCY', 2) # Use the `preload_app!` method when specifying a `workers` number. # This directive tells Puma to first boot the application and load code # before forking the application. This takes advantage of Copy On Write # process behavior so workers use less memory. # -# preload_app! +preload_app! # Allow puma to be restarted by `rails restart` command. plugin :tmp_restart diff --git a/config/routes.rb b/config/routes.rb index 945366d..55886ed 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -19,12 +19,17 @@ Rails.application.routes.draw do end resources :stages, only: %i[show update] resources :teams, only: %i[show update] + resources :accounts, only: %i[show] resources :team_action_items, only: %i[update] resources :tournaments do resources :team_action_lists, only: %i[create] patch 'team_action_lists/:key/teams/:team_id', to: 'team_action_lists#update_item' resources :statistics, only: %i[index] - resources :matches, only: %i[index] + resources :matches, only: %i[index] do + collection do + patch :metadata + end + end resources :beamers, only: %i[index show create update destroy] do collection do get :by_name @@ -32,12 +37,16 @@ Rails.application.routes.draw do end member do get :timer + get :transaction_log, to: 'tournament_transaction_log_entries#index' patch :timer, action: :set_timer patch :sync_state + patch :start_next_group_matches post :test_sync + delete :playoffs, action: :destroy_playoffs end end resources :match_scores, only: %i[show update] get 'healthz', to: 'health#index' + get 'metrics', to: 'metrics#index' get 'version', to: 'version#show' end diff --git a/config/sidekiq.yml b/config/sidekiq.yml new file mode 100644 index 0000000..32d3b5d --- /dev/null +++ b/config/sidekiq.yml @@ -0,0 +1,2 @@ +--- +:concurrency: <%= ENV.fetch("SIDEKIQ_CONCURRENCY", 5) %> diff --git a/db/migrate/20260428120000_add_timer_reason_to_tournaments.rb b/db/migrate/20260428120000_add_timer_reason_to_tournaments.rb new file mode 100644 index 0000000..f28bafe --- /dev/null +++ b/db/migrate/20260428120000_add_timer_reason_to_tournaments.rb @@ -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 diff --git a/db/migrate/20260430120000_add_sync_auth_token_digest_to_tournaments.rb b/db/migrate/20260430120000_add_sync_auth_token_digest_to_tournaments.rb new file mode 100644 index 0000000..677ef80 --- /dev/null +++ b/db/migrate/20260430120000_add_sync_auth_token_digest_to_tournaments.rb @@ -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 diff --git a/db/migrate/20260430121000_create_tournament_transaction_log_entries.rb b/db/migrate/20260430121000_create_tournament_transaction_log_entries.rb new file mode 100644 index 0000000..141b498 --- /dev/null +++ b/db/migrate/20260430121000_create_tournament_transaction_log_entries.rb @@ -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 diff --git a/db/migrate/20260504100000_add_metadata_to_tournaments.rb b/db/migrate/20260504100000_add_metadata_to_tournaments.rb new file mode 100644 index 0000000..bcb19f0 --- /dev/null +++ b/db/migrate/20260504100000_add_metadata_to_tournaments.rb @@ -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 diff --git a/db/migrate/20260504101000_add_match_metadata_to_matches.rb b/db/migrate/20260504101000_add_match_metadata_to_matches.rb new file mode 100644 index 0000000..4a7b131 --- /dev/null +++ b/db/migrate/20260504101000_add_match_metadata_to_matches.rb @@ -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 diff --git a/db/migrate/20260504120000_create_team_players.rb b/db/migrate/20260504120000_create_team_players.rb new file mode 100644 index 0000000..da3af52 --- /dev/null +++ b/db/migrate/20260504120000_create_team_players.rb @@ -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 diff --git a/db/schema.rb b/db/schema.rb index 002741a..0d5a2ed 100644 --- a/db/schema.rb +++ b/db/schema.rb @@ -10,96 +10,99 @@ # # It's strongly recommended that you check this file into your version control system. -ActiveRecord::Schema[7.0].define(version: 2026_04_26_121000) do +ActiveRecord::Schema[8.1].define(version: 2026_05_04_120000) do create_table "beamers", force: :cascade do |t| - t.string "name", null: false + t.json "config", default: {}, null: false + t.datetime "created_at", null: false t.string "display_state", default: "during_game", null: false t.boolean "is_default", default: false - t.integer "tournament_id", null: false - t.datetime "created_at", null: false - t.datetime "updated_at", null: false + t.string "name", null: false t.integer "sync_source_id" - t.json "config", default: {}, null: false + t.integer "tournament_id", null: false + t.datetime "updated_at", null: false t.index ["tournament_id", "name"], name: "index_beamers_on_tournament_id_and_name", unique: true t.index ["tournament_id"], name: "index_beamers_on_tournament_id" end create_table "bets", force: :cascade do |t| - t.integer "user_id", null: false + t.datetime "created_at", precision: nil, null: false t.integer "match_id", null: false t.integer "team_id" - t.datetime "created_at", precision: nil, null: false t.datetime "updated_at", precision: nil, null: false + t.integer "user_id", null: false t.index ["match_id"], name: "index_bets_on_match_id" t.index ["team_id"], name: "index_bets_on_team_id" t.index ["user_id"], name: "index_bets_on_user_id" end create_table "group_scores", force: :cascade do |t| - t.integer "group_points", default: 0 - t.integer "scored_points", default: 0 - t.integer "received_points", default: 0 - t.integer "team_id", null: false - t.integer "group_id", null: false t.datetime "created_at", precision: nil, null: false - t.datetime "updated_at", precision: nil, null: false + t.integer "group_id", null: false + t.integer "group_points", default: 0 t.integer "position", default: 0 + t.integer "received_points", default: 0 + t.integer "scored_points", default: 0 t.integer "sync_source_id" + t.integer "team_id", null: false + t.datetime "updated_at", precision: nil, null: false t.index ["group_id"], name: "index_group_scores_on_group_id" t.index ["team_id"], name: "index_group_scores_on_team_id" end create_table "groups", force: :cascade do |t| + t.datetime "created_at", precision: nil, null: false t.integer "number" t.integer "stage_id", null: false - t.datetime "created_at", precision: nil, null: false - t.datetime "updated_at", precision: nil, null: false t.integer "sync_source_id" + t.datetime "updated_at", precision: nil, null: false t.index ["stage_id"], name: "index_groups_on_stage_id" end create_table "match_scores", force: :cascade do |t| - t.integer "points", default: 0 - t.integer "match_id", null: false - t.integer "team_id", null: false t.datetime "created_at", precision: nil, null: false - t.datetime "updated_at", precision: nil, null: false t.integer "hidden_points", default: 0, null: false + t.integer "match_id", null: false + t.integer "points", default: 0 t.integer "sync_source_id" + t.integer "team_id", null: false + t.datetime "updated_at", precision: nil, null: false t.index ["match_id"], name: "index_match_scores_on_match_id" t.index ["team_id"], name: "index_match_scores_on_team_id" end create_table "matches", force: :cascade do |t| - t.integer "state", default: 0 + t.datetime "created_at", precision: nil, null: false + t.boolean "decider", default: false, null: false + t.integer "group_id" + t.string "location" + t.text "notes" t.integer "position" t.integer "stage_id" - t.integer "group_id" - t.datetime "created_at", precision: nil, null: false - t.datetime "updated_at", precision: nil, null: false - t.boolean "decider", default: false, null: false + t.string "start_time" + t.integer "state", default: 0 t.integer "sync_source_id" + t.datetime "updated_at", precision: nil, null: false t.index ["group_id"], name: "index_matches_on_group_id" t.index ["stage_id"], name: "index_matches_on_stage_id" end create_table "stages", force: :cascade do |t| + t.datetime "created_at", precision: nil, null: false t.integer "level" t.integer "state", default: 0 - t.integer "tournament_id", null: false - t.datetime "created_at", precision: nil, null: false - t.datetime "updated_at", precision: nil, null: false t.integer "sync_source_id" + t.integer "tournament_id", null: false + t.datetime "updated_at", precision: nil, null: false t.index ["tournament_id"], name: "index_stages_on_tournament_id" end create_table "team_action_items", force: :cascade do |t| - t.integer "team_action_list_id", null: false - t.integer "team_id", null: false t.boolean "completed", default: false, null: false t.datetime "completed_at" - t.integer "sync_source_id" t.datetime "created_at", null: false + t.integer "sync_source_id" + t.integer "team_action_list_id", null: false + t.integer "team_id", null: false t.datetime "updated_at", null: false t.index ["sync_source_id"], name: "index_team_action_items_on_sync_source_id" t.index ["team_action_list_id", "team_id"], name: "index_team_action_items_on_team_action_list_id_and_team_id", unique: true @@ -108,91 +111,122 @@ ActiveRecord::Schema[7.0].define(version: 2026_04_26_121000) do end create_table "team_action_lists", force: :cascade do |t| - t.integer "tournament_id", null: false - t.string "name", null: false - t.string "key", null: false t.string "action_name", null: false + t.datetime "created_at", null: false + t.string "key", null: false + t.string "name", null: false t.string "source", null: false t.integer "sync_source_id" - t.datetime "created_at", null: false + t.integer "tournament_id", null: false t.datetime "updated_at", null: false t.index ["sync_source_id"], name: "index_team_action_lists_on_sync_source_id" t.index ["tournament_id", "key"], name: "index_team_action_lists_on_tournament_id_and_key", unique: true t.index ["tournament_id"], name: "index_team_action_lists_on_tournament_id" end + create_table "team_players", force: :cascade do |t| + t.datetime "created_at", null: false + t.integer "player_id", null: false + t.integer "team_id", null: false + t.datetime "updated_at", null: false + t.index ["player_id"], name: "index_team_players_on_player_id" + t.index ["team_id", "player_id"], name: "index_team_players_on_team_id_and_player_id", unique: true + t.index ["team_id"], name: "index_team_players_on_team_id" + end + create_table "teams", force: :cascade do |t| - t.string "name" - t.integer "tournament_id" t.datetime "created_at", precision: nil, null: false - t.datetime "updated_at", precision: nil, null: false + t.string "name" t.integer "sync_source_id" + t.integer "tournament_id" + t.datetime "updated_at", precision: nil, null: false t.index ["tournament_id"], name: "index_teams_on_tournament_id" end create_table "tournament_sync_queue_entries", force: :cascade do |t| - t.integer "tournament_id", null: false - t.text "snapshot_json", null: false - t.datetime "next_attempt_at", null: false - t.datetime "last_attempt_at" - t.datetime "locked_at" - t.string "lock_token" t.integer "attempts", default: 0, null: false - t.string "status", default: "pending", null: false - t.string "last_error" t.datetime "created_at", null: false + t.datetime "last_attempt_at" + t.string "last_error" + t.string "lock_token" + t.datetime "locked_at" + t.datetime "next_attempt_at", null: false + t.text "snapshot_json", null: false + t.string "status", default: "pending", null: false + t.integer "tournament_id", null: false t.datetime "updated_at", null: false t.index ["next_attempt_at"], name: "index_tournament_sync_queue_entries_on_next_attempt_at" t.index ["status"], name: "index_tournament_sync_queue_entries_on_status" t.index ["tournament_id"], name: "index_tournament_sync_queue_entries_on_tournament_id", unique: true end + create_table "tournament_transaction_log_entries", force: :cascade do |t| + t.string "action", null: false + t.datetime "created_at", null: false + t.text "message", null: false + t.json "metadata", default: {}, null: false + t.integer "sequence", null: false + t.integer "tournament_id", null: false + t.datetime "updated_at", null: false + t.integer "user_id" + t.index ["tournament_id", "sequence"], name: "index_tournament_log_entries_on_tournament_and_sequence", unique: true + t.index ["tournament_id"], name: "index_tournament_transaction_log_entries_on_tournament_id" + t.index ["user_id"], name: "index_tournament_transaction_log_entries_on_user_id" + end + create_table "tournaments", force: :cascade do |t| - t.string "name", null: false t.string "code", null: false + t.datetime "created_at", precision: nil, null: false t.string "description" - t.boolean "public", default: true - t.integer "playoff_teams_amount", default: 0 + t.datetime "ends_at" t.integer "instant_finalists_amount", default: 0 t.integer "intermediate_round_participants_amount", default: 0 - t.integer "user_id", null: false - t.datetime "created_at", precision: nil, null: false - t.datetime "updated_at", precision: nil, null: false - t.datetime "timestamp" + t.string "location" + t.string "name", null: false + t.integer "playoff_teams_amount", default: 0 + t.boolean "public", default: true t.boolean "read_only_mode", default: false, null: false - t.string "sync_target_url" + t.datetime "starts_at" t.string "sync_auth_token" - t.integer "sync_source_tournament_id" - t.datetime "sync_last_pushed_at" - t.string "sync_last_push_error" + t.string "sync_auth_token_digest" t.datetime "sync_last_imported_snapshot_at" + t.string "sync_last_push_error" + t.datetime "sync_last_pushed_at" + t.integer "sync_source_tournament_id" + t.string "sync_target_url" t.string "timer_mode" + t.string "timer_reason" + t.text "timer_reason_text" + t.datetime "timestamp" + t.datetime "updated_at", precision: nil, null: false + t.integer "user_id", null: false + t.string "website_url" t.index ["code"], name: "index_tournaments_on_code", unique: true t.index ["user_id"], name: "index_tournaments_on_user_id" end create_table "users", force: :cascade do |t| - t.string "provider", default: "email", null: false - t.string "uid", default: "", null: false - t.string "encrypted_password", default: "", null: false - t.string "reset_password_token" - t.datetime "reset_password_sent_at", precision: nil t.boolean "allow_password_change", default: false - t.datetime "remember_created_at", precision: nil - t.integer "sign_in_count", default: 0, null: false - t.datetime "current_sign_in_at", precision: nil - t.datetime "last_sign_in_at", precision: nil - t.string "current_sign_in_ip" - t.string "last_sign_in_ip" + t.datetime "confirmation_sent_at", precision: nil t.string "confirmation_token" t.datetime "confirmed_at", precision: nil - t.datetime "confirmation_sent_at", precision: nil - t.string "unconfirmed_email" - t.string "username" - t.string "email" - t.text "tokens" t.datetime "created_at", precision: nil, null: false + t.datetime "current_sign_in_at", precision: nil + t.string "current_sign_in_ip" + t.string "email" + t.string "encrypted_password", default: "", null: false + t.datetime "last_sign_in_at", precision: nil + t.string "last_sign_in_ip" + t.string "provider", default: "email", null: false + t.datetime "remember_created_at", precision: nil + t.datetime "reset_password_sent_at", precision: nil + t.string "reset_password_token" + t.integer "sign_in_count", default: 0, null: false + t.text "tokens" + t.string "uid", default: "", null: false + t.string "unconfirmed_email" t.datetime "updated_at", precision: nil, null: false + t.string "username" t.index ["confirmation_token"], name: "index_users_on_confirmation_token", unique: true t.index ["email"], name: "index_users_on_email", unique: true t.index ["provider"], name: "index_users_on_provider" @@ -216,7 +250,11 @@ ActiveRecord::Schema[7.0].define(version: 2026_04_26_121000) do add_foreign_key "team_action_items", "team_action_lists", on_delete: :cascade add_foreign_key "team_action_items", "teams", on_delete: :cascade add_foreign_key "team_action_lists", "tournaments", on_delete: :cascade + add_foreign_key "team_players", "teams", on_delete: :cascade + add_foreign_key "team_players", "users", column: "player_id", on_delete: :cascade add_foreign_key "teams", "tournaments", on_delete: :cascade add_foreign_key "tournament_sync_queue_entries", "tournaments", on_delete: :cascade + add_foreign_key "tournament_transaction_log_entries", "tournaments", on_delete: :cascade + add_foreign_key "tournament_transaction_log_entries", "users", on_delete: :nullify add_foreign_key "tournaments", "users", on_delete: :cascade end diff --git a/db/seeds.rb b/db/seeds.rb index a5ed829..9245444 100644 --- a/db/seeds.rb +++ b/db/seeds.rb @@ -1,26 +1,34 @@ # frozen_string_literal: true -require 'faker' +if Rails.env.production? && ENV.fetch('TURNIERE_ENABLE_DEMO_SEEDS', nil) != '1' + warn 'Skipping demo seeds in production. Set TURNIERE_ENABLE_DEMO_SEEDS=1 to force them.' +else + begin + require 'faker' + rescue LoadError + warn 'Skipping demo seeds because faker is unavailable.' + else + user = User.create! username: Faker::Internet.username, email: Faker::Internet.email, password: Faker::Internet.password + tournament = user.tournaments.create! name: Faker::Creature::Dog.name -user = User.create! username: Faker::Internet.username, email: Faker::Internet.email, password: Faker::Internet.password -tournament = user.tournaments.create! name: Faker::Creature::Dog.name - -@teams = [] -16.times do - team = tournament.teams.create! name: Faker::Movies::HarryPotter.character - @teams << team -end -4.times do |i| - stage = tournament.stages.create! - stage.level = i - matches_amount = 2**i - matches_amount.times do |j| - match = stage.matches.create! - match.match_scores.create! team: @teams.sample, points: rand(10) - match.match_scores.create! team: @teams.sample, points: rand(10) - match.position = j - match.state = rand(5) - match.save! + @teams = [] + 16.times do + team = tournament.teams.create! name: Faker::Movies::HarryPotter.character + @teams << team + end + 4.times do |i| + stage = tournament.stages.create! + stage.level = i + matches_amount = 2**i + matches_amount.times do |j| + match = stage.matches.create! + match.match_scores.create! team: @teams.sample, points: rand(10) + match.match_scores.create! team: @teams.sample, points: rand(10) + match.position = j + match.state = rand(5) + match.save! + end + stage.save! + end end - stage.save! end diff --git a/doc/account_profiles_api.md b/doc/account_profiles_api.md new file mode 100644 index 0000000..2fd3bc4 --- /dev/null +++ b/doc/account_profiles_api.md @@ -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. diff --git a/doc/leader_follower.md b/doc/leader_follower.md index 632e805..cdc9cb0 100644 --- a/doc/leader_follower.md +++ b/doc/leader_follower.md @@ -24,6 +24,7 @@ Create empty tournament with: - `sync_auth_token: ` Do not send `teams` payload for follower. Backend allows empty read only follower tournament creation. +The token is write-only. Backend responses never return it; owner responses only expose `sync_auth_configured`. ### 2. Create normal leader tournament on local backend @@ -178,9 +179,9 @@ Owner-facing frontend can expose: Recommended owner UX: -1. create remote follower first -2. copy remote `sync_auth_token` -3. paste remote `sync_state` URL into leader +1. generate a shared token outside the backend +2. create remote follower with that `sync_auth_token` +3. paste same token and remote `sync_state` URL into leader 4. save leader sync config 5. show last push time / error state 6. allow explicit follower takeover by disabling `read_only_mode` diff --git a/doc/load_tests.md b/doc/load_tests.md new file mode 100644 index 0000000..eec9039 --- /dev/null +++ b/doc/load_tests.md @@ -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. diff --git a/doc/match_metadata_api.md b/doc/match_metadata_api.md new file mode 100644 index 0000000..f516af8 --- /dev/null +++ b/doc/match_metadata_api.md @@ -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. diff --git a/doc/metrics.md b/doc/metrics.md new file mode 100644 index 0000000..3fc684f --- /dev/null +++ b/doc/metrics.md @@ -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. diff --git a/doc/rails_8_dependency_update.md b/doc/rails_8_dependency_update.md new file mode 100644 index 0000000..d67bc1f --- /dev/null +++ b/doc/rails_8_dependency_update.md @@ -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. diff --git a/doc/timer_api.md b/doc/timer_api.md index dcb1169..b60447f 100644 --- a/doc/timer_api.md +++ b/doc/timer_api.md @@ -15,6 +15,8 @@ Frontend should treat timer state as: - no timer: `timestamp = null`, `timer_mode = null` - countdown: `timer_mode = "countdown"` - countup: `timer_mode = "countup"` +- optional reason: `timer_reason` may be `tournament_start`, `playoff_start`, `match_end`, or `custom` +- custom reason: `timer_reason = "custom"` and `timer_reason_text` contains the display text ## Read Flow @@ -31,7 +33,9 @@ Response shape: ```json { "timestamp": "2026-04-24T10:15:00Z", - "timer_mode": "countdown" + "timer_mode": "countdown", + "timer_reason": "tournament_start", + "timer_reason_text": null } ``` @@ -40,6 +44,13 @@ Possible values: - `timer_mode = "countdown"` - `timer_mode = "countup"` - `timer_mode = null` +- `timer_reason = "tournament_start"` +- `timer_reason = "playoff_start"` +- `timer_reason = "match_end"` +- `timer_reason = "custom"` +- `timer_reason = null` + +`timer_reason_text` is only used when `timer_reason = "custom"`. ## Meaning Of `timestamp` @@ -103,7 +114,8 @@ Request: ```json { - "timestamp_seconds": 120 + "timestamp_seconds": 120, + "timer_reason": "tournament_start" } ``` @@ -120,7 +132,8 @@ Request: ```json { - "timestamp": "2026-04-24T10:15:00Z" + "timestamp": "2026-04-24T10:15:00Z", + "timer_reason": "playoff_start" } ``` @@ -129,7 +142,9 @@ Optional explicit mode: ```json { "timestamp": "2026-04-24T10:15:00Z", - "timer_mode": "countdown" + "timer_mode": "countdown", + "timer_reason": "custom", + "timer_reason_text": "Opening ceremony ran long" } ``` @@ -139,7 +154,8 @@ Request: ```json { - "timer_mode": "countup" + "timer_mode": "countup", + "timer_reason": "match_end" } ``` @@ -157,7 +173,9 @@ Backend also accepts explicit timestamp with countup mode: ```json { "timestamp": "2026-04-24T10:00:00Z", - "timer_mode": "countup" + "timer_mode": "countup", + "timer_reason": "custom", + "timer_reason_text": "Manual overtime" } ``` @@ -167,6 +185,39 @@ Useful for: - rehydrating synced follower state - advanced admin tooling +### Change timer reason without restarting + +After a timer is active, owner may change only the reason fields. +Backend keeps current `timestamp` and `timer_mode` unchanged. + +Set a predefined reason: + +```json +{ + "timer_reason": "playoff_start" +} +``` + +Set a custom reason: + +```json +{ + "timer_reason": "custom", + "timer_reason_text": "Opening ceremony ran long" +} +``` + +Remove reason: + +```json +{ + "timer_reason": null +} +``` + +Response shape is the normal timer state with the same active `timestamp` and +`timer_mode`, plus updated reason fields. + ### Clear timer Request: @@ -179,10 +230,12 @@ Request: Behavior: -- backend clears both fields +- backend clears all timer fields - response returns: - `timestamp = null` - `timer_mode = null` + - `timer_reason = null` + - `timer_reason_text = null` ## Validation Rules @@ -194,6 +247,11 @@ Backend rejects: - `timestamp_seconds` together with `timer_mode = "countup"` - `timer_mode` by itself unless it is `countup` - `clear_timer` mixed with timer values +- invalid `timer_reason` +- reason-only updates when no timer is active +- `timer_reason_text` without `timer_reason` +- `timer_reason = "custom"` without `timer_reason_text` +- `timer_reason_text` with any non-custom `timer_reason` Countdown timestamps must be future timestamps. Countup timestamps may be current or past timestamps. @@ -223,7 +281,9 @@ Payload: "type": "timer.updated", "tournament_id": 123, "timestamp": "2026-04-24T10:15:00Z", - "timer_mode": "countdown" + "timer_mode": "countdown", + "timer_reason": "tournament_start", + "timer_reason_text": null } ``` @@ -243,7 +303,7 @@ Frontend rule: Recommended flow: 1. Load tournament via `GET /tournaments/:id` -2. Read `timer_mode` and `timestamp` +2. Read `timer_mode`, `timestamp`, `timer_reason`, and `timer_reason_text` 3. Open ActionCable subscription for same tournament id 4. Replace local timer state whenever `timer.updated` arrives 5. Derive displayed time from `Date.now()` plus stored backend timestamp @@ -252,9 +312,11 @@ Recommended client state: ```ts type TimerState = - | { mode: null; timestampIso: null } - | { mode: "countdown"; timestampIso: string } - | { mode: "countup"; timestampIso: string }; + | { mode: null; timestampIso: null; reason: null; reasonText: null } + | { mode: "countdown"; timestampIso: string; reason: TimerReason | null; reasonText: string | null } + | { mode: "countup"; timestampIso: string; reason: TimerReason | null; reasonText: string | null }; + +type TimerReason = "tournament_start" | "playoff_start" | "match_end" | "custom"; ``` Suggested derivation logic: @@ -283,7 +345,7 @@ Recommended rendering: Recommended local ticking: -- keep backend state as timestamp + mode +- keep backend state as timestamp + mode + reason - update rendered value with local interval - do not poll backend every second @@ -300,7 +362,7 @@ Timer state is included in tournament sync snapshots. That means: - leader timer changes propagate to follower -- follower tournament exposes same `timestamp` / `timer_mode` +- follower tournament exposes same `timestamp` / `timer_mode` / `timer_reason` / `timer_reason_text` - follower websocket subscribers receive `timer.updated` - follower still rejects timer writes while read only @@ -311,6 +373,7 @@ Implemented coverage: - API surface timer lifecycle: - countdown - countup + - reason-only set / change / remove - clear - validation - [spec/e2e/http/api_surface_spec.rb](../spec/e2e/http/api_surface_spec.rb) @@ -321,5 +384,5 @@ Implemented coverage: Current note: -- single-backend HTTP E2E timer specs pass +- single-backend and local dual-server HTTP E2E timer specs pass - follower blackbox task still has existing Docker/network issue in follow setup, so production-style follow stack verification is not green yet diff --git a/doc/tournament_live_updates.md b/doc/tournament_live_updates.md index 9d8f9f4..fb87d47 100644 --- a/doc/tournament_live_updates.md +++ b/doc/tournament_live_updates.md @@ -70,6 +70,8 @@ Payload: "intermediate_round_participants_amount": 0, "timestamp": null, "timer_mode": null, + "timer_reason": null, + "timer_reason_text": null, "owner_username": "owner", "stages": [], "teams": [], diff --git a/doc/tournament_management_api.md b/doc/tournament_management_api.md new file mode 100644 index 0000000..c2d1ddc --- /dev/null +++ b/doc/tournament_management_api.md @@ -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 diff --git a/doc/tournament_transaction_log_api.md b/doc/tournament_transaction_log_api.md new file mode 100644 index 0000000..b5aa0d7 --- /dev/null +++ b/doc/tournament_transaction_log_api.md @@ -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. diff --git a/docker-compose.blackbox-follow.yml b/docker-compose.blackbox-follow.yml index cfb49d9..4a2d10e 100644 --- a/docker-compose.blackbox-follow.yml +++ b/docker-compose.blackbox-follow.yml @@ -37,12 +37,19 @@ services: source-postgres: condition: service_healthy environment: + RAILS_ENV: ${TURNIERE_BLACKBOX_RAILS_ENV:-production} + WEB_CONCURRENCY: ${TURNIERE_BLACKBOX_WEB_CONCURRENCY:-1} + RAILS_MAX_THREADS: ${TURNIERE_BLACKBOX_RAILS_MAX_THREADS:-5} + RAILS_DB_POOL: ${TURNIERE_BLACKBOX_RAILS_DB_POOL:-5} + SIDEKIQ_CONCURRENCY: ${TURNIERE_BLACKBOX_SIDEKIQ_CONCURRENCY:-5} + DATABASE_URL: postgres://${TURNIERE_BLACKBOX_POSTGRES_USER:-turniere}:${TURNIERE_BLACKBOX_POSTGRES_PASSWORD:-turniere}@source-postgres:5432/${TURNIERE_BLACKBOX_SOURCE_POSTGRES_DB:-turniere_source_blackbox} POSTGRES_URL: postgres://${TURNIERE_BLACKBOX_POSTGRES_USER:-turniere}:${TURNIERE_BLACKBOX_POSTGRES_PASSWORD:-turniere}@source-postgres:5432/${TURNIERE_BLACKBOX_SOURCE_POSTGRES_DB:-turniere_source_blackbox} POSTGRES_USERNAME: ${TURNIERE_BLACKBOX_POSTGRES_USER:-turniere} POSTGRES_PASSWORD: ${TURNIERE_BLACKBOX_POSTGRES_PASSWORD:-turniere} SECRET_KEY_BASE: ${TURNIERE_BLACKBOX_SECRET_KEY_BASE:-turniere-blackbox-secret-key-base-please-change-in-real-prod} MAILGUN_API_KEY: ${TURNIERE_BLACKBOX_MAILGUN_API_KEY:-blackbox-test-api-key} MAILGUN_DOMAIN: ${TURNIERE_BLACKBOX_MAILGUN_DOMAIN:-blackbox.example.com} + TURNIERE_DISABLE_EMAIL_DELIVERY: ${TURNIERE_BLACKBOX_DISABLE_EMAIL_DELIVERY:-1} TOURNAMENT_SYNC_HTTP_TIMEOUT_SECONDS: ${TURNIERE_BLACKBOX_TOURNAMENT_SYNC_HTTP_TIMEOUT_SECONDS:-3} RAILS_LOG_TO_STDOUT: "1" RAILS_SERVE_STATIC_FILES: "1" @@ -58,12 +65,19 @@ services: follower-postgres: condition: service_healthy environment: + RAILS_ENV: ${TURNIERE_BLACKBOX_RAILS_ENV:-production} + WEB_CONCURRENCY: ${TURNIERE_BLACKBOX_WEB_CONCURRENCY:-1} + RAILS_MAX_THREADS: ${TURNIERE_BLACKBOX_RAILS_MAX_THREADS:-5} + RAILS_DB_POOL: ${TURNIERE_BLACKBOX_RAILS_DB_POOL:-5} + SIDEKIQ_CONCURRENCY: ${TURNIERE_BLACKBOX_SIDEKIQ_CONCURRENCY:-5} + DATABASE_URL: postgres://${TURNIERE_BLACKBOX_POSTGRES_USER:-turniere}:${TURNIERE_BLACKBOX_POSTGRES_PASSWORD:-turniere}@follower-postgres:5432/${TURNIERE_BLACKBOX_FOLLOWER_POSTGRES_DB:-turniere_follower_blackbox} POSTGRES_URL: postgres://${TURNIERE_BLACKBOX_POSTGRES_USER:-turniere}:${TURNIERE_BLACKBOX_POSTGRES_PASSWORD:-turniere}@follower-postgres:5432/${TURNIERE_BLACKBOX_FOLLOWER_POSTGRES_DB:-turniere_follower_blackbox} POSTGRES_USERNAME: ${TURNIERE_BLACKBOX_POSTGRES_USER:-turniere} POSTGRES_PASSWORD: ${TURNIERE_BLACKBOX_POSTGRES_PASSWORD:-turniere} SECRET_KEY_BASE: ${TURNIERE_BLACKBOX_SECRET_KEY_BASE:-turniere-blackbox-secret-key-base-please-change-in-real-prod} MAILGUN_API_KEY: ${TURNIERE_BLACKBOX_MAILGUN_API_KEY:-blackbox-test-api-key} MAILGUN_DOMAIN: ${TURNIERE_BLACKBOX_MAILGUN_DOMAIN:-blackbox.example.com} + TURNIERE_DISABLE_EMAIL_DELIVERY: ${TURNIERE_BLACKBOX_DISABLE_EMAIL_DELIVERY:-1} TOURNAMENT_SYNC_HTTP_TIMEOUT_SECONDS: ${TURNIERE_BLACKBOX_TOURNAMENT_SYNC_HTTP_TIMEOUT_SECONDS:-3} RAILS_LOG_TO_STDOUT: "1" RAILS_SERVE_STATIC_FILES: "1" diff --git a/docker-compose.blackbox.yml b/docker-compose.blackbox.yml index fae52d2..2762984 100644 --- a/docker-compose.blackbox.yml +++ b/docker-compose.blackbox.yml @@ -19,12 +19,19 @@ services: postgres: condition: service_healthy environment: + RAILS_ENV: ${TURNIERE_BLACKBOX_RAILS_ENV:-production} + WEB_CONCURRENCY: ${TURNIERE_BLACKBOX_WEB_CONCURRENCY:-1} + RAILS_MAX_THREADS: ${TURNIERE_BLACKBOX_RAILS_MAX_THREADS:-5} + RAILS_DB_POOL: ${TURNIERE_BLACKBOX_RAILS_DB_POOL:-5} + SIDEKIQ_CONCURRENCY: ${TURNIERE_BLACKBOX_SIDEKIQ_CONCURRENCY:-5} + DATABASE_URL: postgres://${TURNIERE_BLACKBOX_POSTGRES_USER:-turniere}:${TURNIERE_BLACKBOX_POSTGRES_PASSWORD:-turniere}@postgres:5432/${TURNIERE_BLACKBOX_POSTGRES_DB:-turniere_blackbox} POSTGRES_URL: postgres://${TURNIERE_BLACKBOX_POSTGRES_USER:-turniere}:${TURNIERE_BLACKBOX_POSTGRES_PASSWORD:-turniere}@postgres:5432/${TURNIERE_BLACKBOX_POSTGRES_DB:-turniere_blackbox} POSTGRES_USERNAME: ${TURNIERE_BLACKBOX_POSTGRES_USER:-turniere} POSTGRES_PASSWORD: ${TURNIERE_BLACKBOX_POSTGRES_PASSWORD:-turniere} SECRET_KEY_BASE: ${TURNIERE_BLACKBOX_SECRET_KEY_BASE:-turniere-blackbox-secret-key-base-please-change-in-real-prod} MAILGUN_API_KEY: ${TURNIERE_BLACKBOX_MAILGUN_API_KEY:-blackbox-test-api-key} MAILGUN_DOMAIN: ${TURNIERE_BLACKBOX_MAILGUN_DOMAIN:-blackbox.example.com} + TURNIERE_DISABLE_EMAIL_DELIVERY: ${TURNIERE_BLACKBOX_DISABLE_EMAIL_DELIVERY:-1} RAILS_LOG_TO_STDOUT: "1" RAILS_SERVE_STATIC_FILES: "1" ports: diff --git a/e2e/lib/api_client.rb b/e2e/lib/api_client.rb index d0a8974..6ea0198 100644 --- a/e2e/lib/api_client.rb +++ b/e2e/lib/api_client.rb @@ -80,7 +80,7 @@ module TurniereE2E login!(**@login_credentials) if refresh_session && should_refresh_session?(method, path) uri = URI.parse("#{base_url}#{path}") - uri.query = URI.encode_www_form(params) if params.present? + uri.query = URI.encode_www_form(params) if params&.any? request_class = case method when :get then Net::HTTP::Get diff --git a/e2e/lib/load_test_runner.rb b/e2e/lib/load_test_runner.rb new file mode 100644 index 0000000..ea93da4 --- /dev/null +++ b/e2e/lib/load_test_runner.rb @@ -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 diff --git a/e2e/lib/scenario_runner.rb b/e2e/lib/scenario_runner.rb index 559cdc0..7d68af9 100644 --- a/e2e/lib/scenario_runner.rb +++ b/e2e/lib/scenario_runner.rb @@ -12,7 +12,7 @@ module TurniereE2E attr_reader :client def initialize(base_url:, email: nil, password: nil, username: nil) - @client = ApiClient.new(base_url:) + @client = ApiClient.new(base_url: base_url) @email = email @password = password @username = username @@ -30,31 +30,25 @@ module TurniereE2E timer_response = patch!( "/tournaments/#{tournament.fetch(:id)}/timer", - body: { timestamp_seconds: 600 } + body: { timestamp_seconds: 600, timer_reason: 'tournament_start' } ) - checkpoints[:timer_set] = { timestamp: timer_response.fetch(:timestamp) } - if stop_at == :timer_set - return result(session:, tournament:, checkpoints:, - stop_at:) - end + checkpoints[:timer_set] = { + timestamp: timer_response.fetch(:timestamp), + timer_reason: timer_response.fetch(:timer_reason) + } + return checkpoint_result(session, tournament, checkpoints, stop_at) if stop_at == :timer_set upcoming_matches = get!( "/tournaments/#{tournament.fetch(:id)}/matches", params: { state: 'upcoming' } ) checkpoints[:upcoming_group_matches] = { match_ids: upcoming_matches.map { |match| match.fetch(:id) } } - if stop_at == :upcoming_group_matches - return result(session:, tournament:, checkpoints:, - stop_at:) - end + return checkpoint_result(session, tournament, checkpoints, stop_at) if stop_at == :upcoming_group_matches finish_group_stage_matches!(tournament) tournament = fetch_tournament!(tournament.fetch(:id)) checkpoints[:groups_finished] = summarize_tournament(tournament) - if stop_at == :groups_finished - return result(session:, tournament:, checkpoints:, - stop_at:) - end + return checkpoint_result(session, tournament, checkpoints, stop_at) if stop_at == :groups_finished statistics = get!("/tournaments/#{tournament.fetch(:id)}/statistics") checkpoints[:statistics] = { @@ -66,27 +60,21 @@ module TurniereE2E finish_stage!(group_stage_of!(tournament).fetch(:id)) tournament = fetch_tournament!(tournament.fetch(:id)) checkpoints[:playoffs_created] = summarize_tournament(tournament) - if stop_at == :playoffs_created - return result(session:, tournament:, checkpoints:, - stop_at:) - end + return checkpoint_result(session, tournament, checkpoints, stop_at) if stop_at == :playoffs_created run_playoffs_until_before_final!(tournament.fetch(:id)) tournament = fetch_tournament!(tournament.fetch(:id)) checkpoints[:before_final_finish_attempt] = summarize_tournament(tournament) - if stop_at == :before_final_finish_attempt - return result(session:, tournament:, checkpoints:, - stop_at:) - end + return checkpoint_result(session, tournament, checkpoints, stop_at) if stop_at == :before_final_finish_attempt finish_attempt = finish_final_stage_matches!(tournament) tournament = fetch_tournament!(tournament.fetch(:id)) checkpoints[:final_finish_attempted] = { - finish_attempt:, + finish_attempt: finish_attempt, tournament: summarize_tournament(tournament) } - result(session:, tournament:, checkpoints:, stop_at:) + result(session: session, tournament: tournament, checkpoints: checkpoints, stop_at: stop_at) end def run_group_stage_intermediate_round(stop_at: :final_finish_attempted) @@ -99,8 +87,8 @@ module TurniereE2E created: summarize_tournament(tournament) } if stop_at == :created - return result(session:, tournament:, checkpoints:, - stop_at:) + return result(session: session, tournament: tournament, checkpoints: checkpoints, + stop_at: stop_at) end finish_group_stage_matches!(tournament) @@ -108,44 +96,87 @@ module TurniereE2E tournament = fetch_tournament!(tournament.fetch(:id)) checkpoints[:playoffs_created] = summarize_tournament(tournament) if stop_at == :playoffs_created - return result(session:, tournament:, checkpoints:, - stop_at:) + return result(session: session, tournament: tournament, checkpoints: checkpoints, + stop_at: stop_at) end run_playoffs_until_before_final!(tournament.fetch(:id)) tournament = fetch_tournament!(tournament.fetch(:id)) checkpoints[:before_final_finish_attempt] = summarize_tournament(tournament) if stop_at == :before_final_finish_attempt - return result(session:, tournament:, checkpoints:, - stop_at:) + return result(session: session, tournament: tournament, checkpoints: checkpoints, + stop_at: stop_at) end finish_attempt = finish_final_stage_matches!(tournament) tournament = fetch_tournament!(tournament.fetch(:id)) checkpoints[:final_finish_attempted] = { - finish_attempt:, + finish_attempt: finish_attempt, tournament: summarize_tournament(tournament) } - result(session:, tournament:, checkpoints:, stop_at:) + result(session: session, tournament: tournament, checkpoints: checkpoints, stop_at: stop_at) + end + + def run_group_stage_playoff_recreation(stop_at: :third_playoffs_created) + session = register_session! + tournament = create_tournament!( + create_group_stage_payload(group_count: 2, teams_per_group: 2, playoff_teams_amount: 2) + ) + + checkpoints = { + created: summarize_tournament(tournament) + } + rounds = playoff_recreation_rounds + created_keys = %i[first_playoffs_created second_playoffs_created third_playoffs_created] + deleted_keys = %i[first_playoffs_deleted second_playoffs_deleted] + + rounds.each_with_index do |round, index| + tournament = fetch_tournament!(tournament.fetch(:id)) + apply_group_score_round!(tournament, round.fetch(:scores_by_team_name)) + finish_stage!(group_stage_of!(fetch_tournament!(tournament.fetch(:id))).fetch(:id)) + tournament = fetch_tournament!(tournament.fetch(:id)) + + created_key = created_keys.fetch(index) + checkpoints[created_key] = summarize_tournament(tournament).merge( + playoff_matchups: playoff_matchups(tournament) + ) + if stop_at == created_key + return result(session: session, tournament: tournament, checkpoints: checkpoints, + stop_at: stop_at) + end + + next if index == rounds.size - 1 + + delete_playoffs!(tournament.fetch(:id)) + tournament = fetch_tournament!(tournament.fetch(:id)) + deleted_key = deleted_keys.fetch(index) + checkpoints[deleted_key] = summarize_tournament(tournament) + if stop_at == deleted_key + return result(session: session, tournament: tournament, checkpoints: checkpoints, + stop_at: stop_at) + end + end + + result(session: session, tournament: tournament, checkpoints: checkpoints, stop_at: stop_at) end def run_playoff_only(team_count:, stop_at: :completed) session = register_session! - tournament = create_tournament!(create_playoff_only_payload(team_count:)) + tournament = create_tournament!(create_playoff_only_payload(team_count: team_count)) checkpoints = { created: summarize_tournament(tournament) } if stop_at == :created - return result(session:, tournament:, checkpoints:, - stop_at:) + return result(session: session, tournament: tournament, checkpoints: checkpoints, + stop_at: stop_at) end run_playoffs_until_complete!(tournament.fetch(:id)) tournament = fetch_tournament!(tournament.fetch(:id)) checkpoints[:completed] = summarize_tournament(tournament) - result(session:, tournament:, checkpoints:, stop_at:) + result(session: session, tournament: tournament, checkpoints: checkpoints, stop_at: stop_at) end def run_group_direct_comparison_tiebreak(case_name:, stop_at: :group_scores) @@ -158,8 +189,8 @@ module TurniereE2E created: summarize_tournament(tournament) } if stop_at == :created - return result(session:, tournament:, checkpoints:, - stop_at:) + return result(session: session, tournament: tournament, checkpoints: checkpoints, + stop_at: stop_at) end group = group_stage_of!(tournament).fetch(:groups).first @@ -172,16 +203,68 @@ module TurniereE2E } tournament = fetch_tournament!(tournament.fetch(:id)) - result(session:, tournament:, checkpoints:, stop_at:) + result(session: session, tournament: tournament, checkpoints: checkpoints, stop_at: stop_at) + end + + def run_transaction_log_history(stop_at: :transaction_log) + session = register_session! + tournament = create_tournament!( + create_group_stage_payload(group_count: 1, teams_per_group: 2, playoff_teams_amount: 1) + ) + + checkpoints = { + created: summarize_tournament(tournament) + } + + group_stage = group_stage_of!(tournament) + group = group_stage.fetch(:groups).first + match = group.fetch(:matches).first + team_ids = match.fetch(:match_scores).map { |score| score.dig(:team, :id) } + + play_match_with_points!( + match, + points_by_team_name: { + 'G1-S1' => 1, + 'G1-S2' => 1 + } + ) + checkpoints[:drawn_group_match] = fetch_match!(match.fetch(:id)) + + failed_playoff_start = client.patch("/stages/#{group_stage.fetch(:id)}", body: { state: 'finished' }) + expect_status!(failed_playoff_start, 422, 'finish group stage without decider') + checkpoints[:failed_playoff_start] = failed_playoff_start.fetch(:json) + + decider = create_decider_match!(group.fetch(:id), team_ids) + play_match_with_hidden_points!( + decider, + hidden_points_by_team_name: { + 'G1-S1' => 2, + 'G1-S2' => 1 + } + ) + checkpoints[:decider_finished] = fetch_match!(decider.fetch(:id)) + + finish_stage!(group_stage.fetch(:id)) + tournament = fetch_tournament!(tournament.fetch(:id)) + checkpoints[:playoffs_created] = summarize_tournament(tournament) + + transaction_log = get!("/tournaments/#{tournament.fetch(:id)}/transaction_log") + checkpoints[:transaction_log] = { + actions: transaction_log.map { |entry| entry.fetch(:action) }, + sequences: transaction_log.map { |entry| entry.fetch(:sequence) }, + messages: transaction_log.map { |entry| entry.fetch(:message) } + } + + result(session: session, tournament: tournament, checkpoints: checkpoints, stop_at: stop_at) end def run_group_stage_render_profile(group_count:, teams_per_group:, playoff_teams_amount:, stop_at: :profiled_show) session = register_session! tournament = create_tournament!( create_group_stage_payload( - group_count:, - teams_per_group:, - playoff_teams_amount: + group_count: group_count, + teams_per_group: teams_per_group, + playoff_teams_amount: playoff_teams_amount ) ) @@ -189,18 +272,18 @@ module TurniereE2E created: summarize_tournament(tournament) } if stop_at == :created - return result(session:, tournament:, checkpoints:, - stop_at:) + return result(session: session, tournament: tournament, checkpoints: checkpoints, + stop_at: stop_at) end profiled_show = fetch_tournament_profile!(tournament.fetch(:id)) checkpoints[:profiled_show] = profiled_show - result(session:, tournament: profiled_show.fetch(:tournament), checkpoints:, stop_at:) + result(session: session, tournament: profiled_show.fetch(:tournament), checkpoints: checkpoints, stop_at: stop_at) end def wait_for_healthcheck!(timeout_seconds: 30) - started_at = Time.zone.now + started_at = Process.clock_gettime(Process::CLOCK_MONOTONIC) loop do begin response = client.get('/healthz') @@ -209,7 +292,8 @@ module TurniereE2E nil end - raise "healthcheck did not become ready within #{timeout_seconds}s" if Time.zone.now - started_at > timeout_seconds + elapsed = Process.clock_gettime(Process::CLOCK_MONOTONIC) - started_at + raise "healthcheck did not become ready within #{timeout_seconds}s" if elapsed > timeout_seconds sleep 0.5 end @@ -223,11 +307,11 @@ module TurniereE2E unique = SecureRandom.hex(6) email = "e2e-#{unique}@example.com" username = "e2e-#{unique}" - client.register!(email:, password: DEFAULT_PASSWORD, username:) - return { email:, username: } if client.authenticated? + client.register!(email: email, password: DEFAULT_PASSWORD, username: username) + return { email: email, username: username } if client.authenticated? - login_response = client.login!(email:, password: DEFAULT_PASSWORD) - return { email:, username: } if login_response.fetch(:status) == 200 && client.authenticated? + login_response = client.login!(email: email, password: DEFAULT_PASSWORD) + return { email: email, username: username } if login_response.fetch(:status) == 200 && client.authenticated? raise ApiError.new( 'authentication bootstrap failed; provide a confirmed E2E user via TURNIERE_E2E_EMAIL and TURNIERE_E2E_PASSWORD', @@ -236,6 +320,20 @@ module TurniereE2E ) end + def login_with_existing_credentials! + login_response = client.login!(email: @email, password: @password) + expect_status!(login_response, 200, "login #{@email}") + unless client.authenticated? + raise ApiError.new( + 'login succeeded but no auth headers were returned', + status: 200, + body: login_response.fetch(:json) + ) + end + + { email: @email, username: @username || @email } + end + def create_tournament!(payload) response = client.post('/tournaments', body: payload) expect_status!(response, 201, 'create tournament') @@ -249,19 +347,19 @@ module TurniereE2E end def get!(path, params: nil) - response = get_response!(path, params:) + response = get_response!(path, params: params) expect_status!(response, 200, "get #{path}") response.fetch(:json) end def get_response!(path, params: nil) - response = client.get(path, params:) + response = client.get(path, params: params) expect_status!(response, 200, "get #{path}") response end def patch!(path, body:) - response = client.patch(path, body:) + response = client.patch(path, body: body) expect_status!(response, 200, "patch #{path}") response.fetch(:json) end @@ -315,7 +413,7 @@ module TurniereE2E refreshed_match = fetch_match!(match.fetch(:id)) refreshed_match.fetch(:match_scores).sort_by { |score| score.fetch(:id) }.each do |score| points = score.dig(:team, :name) == winner_name ? 10 : 1 - updated_score = client.patch("/match_scores/#{score.fetch(:id)}", body: { points: }) + updated_score = client.patch("/match_scores/#{score.fetch(:id)}", body: { points: points }) expect_status!(updated_score, 200, "update match_score #{score.fetch(:id)}") end @@ -345,7 +443,7 @@ module TurniereE2E points = points_by_team_name.fetch(team_name) do raise "missing score for #{team_name.inspect} in match #{match.fetch(:id)}" end - updated_score = client.patch("/match_scores/#{score.fetch(:id)}", body: { points: }) + updated_score = client.patch("/match_scores/#{score.fetch(:id)}", body: { points: points }) expect_status!(updated_score, 200, "update match_score #{score.fetch(:id)}") end @@ -365,6 +463,36 @@ module TurniereE2E } end + def play_match_with_hidden_points!(match, hidden_points_by_team_name:, expected_finish_status: 200) + started = client.patch("/matches/#{match.fetch(:id)}", body: { state: 'in_progress' }) + expect_status!(started, 200, "start match #{match.fetch(:id)}") + + refreshed_match = fetch_match!(match.fetch(:id)) + refreshed_match.fetch(:match_scores).each do |score| + team_name = score.dig(:team, :name) + hidden_points = hidden_points_by_team_name.fetch(team_name) do + raise "missing hidden score for #{team_name.inspect} in match #{match.fetch(:id)}" + end + updated_score = client.patch("/match_scores/#{score.fetch(:id)}", body: { hidden_points: hidden_points }) + expect_status!(updated_score, 200, "update hidden match_score #{score.fetch(:id)}") + end + + finished = client.patch("/matches/#{match.fetch(:id)}", body: { state: 'finished' }) + expect_status!(finished, expected_finish_status, "finish match #{match.fetch(:id)}") + + { + match_id: match.fetch(:id), + status: finished.fetch(:status), + body: finished.fetch(:json) + } + end + + def create_decider_match!(group_id, team_ids) + response = client.post("/groups/#{group_id}/matches", body: { team_ids: team_ids }) + expect_status!(response, 201, "create decider match for group #{group_id}") + response.fetch(:json) + end + def fetch_match!(match_id) response = client.get("/matches/#{match_id}") expect_status!(response, 200, "fetch match #{match_id}") @@ -377,6 +505,12 @@ module TurniereE2E response.fetch(:json) end + def delete_playoffs!(tournament_id) + response = client.delete("/tournaments/#{tournament_id}/playoffs") + expect_status!(response, 200, "delete playoffs for tournament #{tournament_id}") + response.fetch(:json) + end + def group_stage_of!(tournament) tournament.fetch(:stages).find { |stage| stage.fetch(:level) == -1 } || raise('group stage not found') end @@ -400,8 +534,8 @@ module TurniereE2E { status: 200, finished_match_ids: final_stage_matches_of!(fetch_tournament!(tournament.fetch(:id))).map do |match| - match.fetch(:id) - end + match.fetch(:id) + end } end @@ -431,7 +565,35 @@ module TurniereE2E match = matches_by_teams.fetch(team_names.sort) do raise "match for #{team_names.inspect} not found" end - play_match_with_points!(match, points_by_team_name:) + play_match_with_points!(match, points_by_team_name: points_by_team_name) + end + end + + def apply_group_score_round!(tournament, scores_by_team_name) + group_stage_of!(tournament).fetch(:groups).each do |group| + group.fetch(:matches).each do |match| + points_by_team_name = match.fetch(:match_scores).to_h do |score| + team_name = score.dig(:team, :name) + [team_name, scores_by_team_name.fetch(team_name)] + end + + if match.fetch(:state) == 'not_started' + play_match_with_points!(match, points_by_team_name: points_by_team_name) + else + update_match_scores!(match, points_by_team_name: points_by_team_name) + end + end + end + end + + def update_match_scores!(match, points_by_team_name:) + refreshed_match = fetch_match!(match.fetch(:id)) + refreshed_match.fetch(:match_scores).each do |score| + team_name = score.dig(:team, :name) + updated_score = client.patch("/match_scores/#{score.fetch(:id)}", body: { + points: points_by_team_name.fetch(team_name) + }) + expect_status!(updated_score, 200, "update match_score #{score.fetch(:id)}") end end @@ -464,11 +626,40 @@ module TurniereE2E description: 'HTTP lifecycle E2E tournament', public: false, group_stage: true, - playoff_teams_amount:, - teams: + playoff_teams_amount: playoff_teams_amount, + teams: teams } end + def playoff_recreation_rounds + [ + { + scores_by_team_name: { + 'G1-S1' => 3, + 'G1-S2' => 1, + 'G2-S1' => 3, + 'G2-S2' => 1 + } + }, + { + scores_by_team_name: { + 'G1-S1' => 0, + 'G1-S2' => 4, + 'G2-S1' => 0, + 'G2-S2' => 4 + } + }, + { + scores_by_team_name: { + 'G1-S1' => 5, + 'G1-S2' => 2, + 'G2-S1' => 2, + 'G2-S2' => 5 + } + } + ] + end + def create_playoff_only_payload(team_count:) { name: "HTTP E2E Playoff Only #{team_count} #{SecureRandom.hex(3)}", @@ -559,6 +750,19 @@ module TurniereE2E } end + def playoff_matchups(tournament) + entry_stage = tournament.fetch(:stages) + .reject { |stage| stage.fetch(:level) == -1 } + .max_by { |stage| stage.fetch(:level) } + entry_stage.fetch(:matches) + .sort_by { |match| match.fetch(:position) } + .map do |match| + match.fetch(:match_scores) + .map { |score| score.dig(:team, :name) } + .sort + end + end + def fetch_tournament_profile!(tournament_id) started_at = Process.clock_gettime(Process::CLOCK_MONOTONIC) response = get_response!("/tournaments/#{tournament_id}", params: { profile: 'true' }) @@ -574,7 +778,7 @@ module TurniereE2E end def parse_server_timing(header) - return {} if header.blank? + return {} if header.to_s.empty? header.split(',').each_with_object({}) do |entry, sections| name, duration = entry.strip.match(/\A([^;]+);dur=([0-9.]+)\z/)&.captures @@ -586,13 +790,17 @@ module TurniereE2E def result(session:, tournament:, checkpoints:, stop_at:) { - session:, + session: session, stop_at: stop_at.to_s, tournament: summarize_tournament(tournament), - checkpoints: + checkpoints: checkpoints } end + def checkpoint_result(session, tournament, checkpoints, stop_at) + result(session: session, tournament: tournament, checkpoints: checkpoints, stop_at: stop_at) + end + def expect_status!(response, expected_status, action) return if response.fetch(:status) == expected_status @@ -604,14 +812,3 @@ module TurniereE2E end end end - -def login_with_existing_credentials! - login_response = client.login!(email: @email, password: @password) - expect_status!(login_response, 200, "login #{@email}") - unless client.authenticated? - raise ApiError.new('login succeeded but no auth headers were returned', status: 200, - body: login_response.fetch(:json)) - end - - { email: @email, username: @username || @email } -end diff --git a/e2e/load_test.rb b/e2e/load_test.rb new file mode 100755 index 0000000..1ca9e07 --- /dev/null +++ b/e2e/load_test.rb @@ -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)}" +) diff --git a/script/e2e_scenarios.rb b/script/e2e_scenarios.rb index aafd662..17d6741 100755 --- a/script/e2e_scenarios.rb +++ b/script/e2e_scenarios.rb @@ -52,6 +52,8 @@ result = case scenario runner.run_group_stage_main_usecase(stop_at: options[:stop_at] || :final_finish_attempted) when 'group_stage_intermediate_round' runner.run_group_stage_intermediate_round(stop_at: options[:stop_at] || :final_finish_attempted) + when 'group_stage_playoff_recreation' + runner.run_group_stage_playoff_recreation(stop_at: options[:stop_at] || :third_playoffs_created) when 'playoff_only' runner.run_playoff_only(team_count: options[:team_count], stop_at: options[:stop_at] || :completed) when 'group_stage_render_profile' diff --git a/spec/auth_helpers.rb b/spec/auth_helpers.rb index ea55d1f..e564cce 100644 --- a/spec/auth_helpers.rb +++ b/spec/auth_helpers.rb @@ -4,5 +4,9 @@ module AuthHelpers def apply_authentication_headers_for(user) user_headers = user.create_new_auth_token request.headers.merge!(user_headers) + return unless respond_to?(:controller) + + allow(controller).to receive(:authenticate_user!).and_return(true) + allow(controller).to receive(:current_user).and_return(user) end end diff --git a/spec/config/scaling_env_spec.rb b/spec/config/scaling_env_spec.rb new file mode 100644 index 0000000..791dd0c --- /dev/null +++ b/spec/config/scaling_env_spec.rb @@ -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 diff --git a/spec/controllers/accounts_controller_spec.rb b/spec/controllers/accounts_controller_spec.rb new file mode 100644 index 0000000..95ac9b4 --- /dev/null +++ b/spec/controllers/accounts_controller_spec.rb @@ -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 diff --git a/spec/controllers/beamers_controller_spec.rb b/spec/controllers/beamers_controller_spec.rb index 5e1f8e8..5b58f0a 100644 --- a/spec/controllers/beamers_controller_spec.rb +++ b/spec/controllers/beamers_controller_spec.rb @@ -77,9 +77,9 @@ RSpec.describe BeamersController, type: :controller do before(:each) { apply_authentication_headers_for @owner } it 'creates a new beamer' do - expect do + expect { post :create, params: { tournament_id: @tournament.to_param }.merge(valid_params) - end.to change(@tournament.beamers, :count).by(1) + }.to change(@tournament.beamers, :count).by(1) expect(response).to have_http_status(:created) end @@ -95,14 +95,14 @@ RSpec.describe BeamersController, type: :controller do post :create, params: { tournament_id: @tournament.to_param, name: 'bad', display_state: 'invalid' } - expect(response).to have_http_status(:unprocessable_entity) + expect(response).to have_http_status(:unprocessable_content) end it 'rejects duplicate name in same tournament' do post :create, params: { tournament_id: @tournament.to_param, name: @beamer.name, display_state: 'during_game' } - expect(response).to have_http_status(:unprocessable_entity) + expect(response).to have_http_status(:unprocessable_content) end end @@ -157,7 +157,7 @@ RSpec.describe BeamersController, type: :controller do tournament_id: @tournament.to_param, id: @beamer.to_param, display_state: 'bogus' } - expect(response).to have_http_status(:unprocessable_entity) + expect(response).to have_http_status(:unprocessable_content) end end @@ -194,11 +194,11 @@ RSpec.describe BeamersController, type: :controller do before(:each) { apply_authentication_headers_for @owner } it 'deletes the beamer' do - expect do + expect { delete :destroy, params: { tournament_id: @tournament.to_param, id: @beamer.to_param } - end.to change(@tournament.beamers, :count).by(-1) + }.to change(@tournament.beamers, :count).by(-1) expect(response).to have_http_status(:no_content) end end diff --git a/spec/controllers/groups_controller_spec.rb b/spec/controllers/groups_controller_spec.rb index 8b535c8..5afecae 100644 --- a/spec/controllers/groups_controller_spec.rb +++ b/spec/controllers/groups_controller_spec.rb @@ -24,18 +24,17 @@ RSpec.describe GroupsController, type: :controller do it 'returns ranking_decision metadata for group scores' do tournament = @group.stage.tournament - tournament.update!(playoff_teams_amount: 2, instant_finalists_amount: 2, - intermediate_round_participants_amount: 0) + tournament.update!(playoff_teams_amount: 2, instant_finalists_amount: 2, intermediate_round_participants_amount: 0) @group.stage.update!(level: -1) @group.matches.destroy_all @group.group_scores.destroy_all - team_a = create(:team, tournament:, name: 'A') - team_b = create(:team, tournament:, name: 'B') + team_a = create(:team, tournament: tournament, name: 'A') + team_b = create(:team, tournament: tournament, name: 'B') create(:group_match, group: @group, state: :finished).tap do |match| - create(:match_score, match:, team: team_a, points: 3) - create(:match_score, match:, team: team_b, points: 1) + create(:match_score, match: match, team: team_a, points: 3) + create(:match_score, match: match, team: team_b, points: 1) end score_a = create(:group_score, group: @group, team: team_a, group_points: 3, scored_points: 3, received_points: 1) diff --git a/spec/controllers/match_scores_controller_spec.rb b/spec/controllers/match_scores_controller_spec.rb index f9cec18..94f1fc4 100644 --- a/spec/controllers/match_scores_controller_spec.rb +++ b/spec/controllers/match_scores_controller_spec.rb @@ -51,6 +51,23 @@ RSpec.describe MatchScoresController, type: :controller do expect(body[:points]).to eq(valid_update[:points]) expect(body[:hidden_points]).to eq(valid_update[:hidden_points]) end + + it 'writes a transaction log entry for the score change' do + expect(TournamentLiveBroadcast).to receive(:call).with(@match_score.tournament.id) + + expect do + put :update, params: { id: @match_score.to_param }.merge(valid_update) + end.to change { @match_score.tournament.transaction_log_entries.count }.by(1) + + entry = @match_score.tournament.transaction_log_entries.last + expect(entry.action).to eq('match_score.updated') + expect(entry.message).to include("changed matchscore #{@match_score.id}") + expect(entry.metadata['match_score_id']).to eq(@match_score.id) + expect(entry.metadata['changes']['points']).to eq( + 'from' => @match_score.points, + 'to' => valid_update[:points] + ) + end end context 'when match_score update fails' do @@ -62,7 +79,7 @@ RSpec.describe MatchScoresController, type: :controller do it 'returns unprocessable entity' do put :update, params: { id: @match_score.to_param }.merge(valid_update) - expect(response).to have_http_status(:unprocessable_entity) + expect(response).to have_http_status(:unprocessable_content) end end end diff --git a/spec/controllers/matches_controller_spec.rb b/spec/controllers/matches_controller_spec.rb index bcb838f..3835f84 100644 --- a/spec/controllers/matches_controller_spec.rb +++ b/spec/controllers/matches_controller_spec.rb @@ -47,7 +47,7 @@ RSpec.describe MatchesController, type: :controller do get :index, params: { tournament_id: @tournament.to_param } match = deserialize_response(response).first - expect(match.keys).to match_array(%i[id position state teams match_scores group stage]) + expect(match.keys).to match_array(%i[id position state location start_time notes teams match_scores group stage]) expect(match[:teams]).to all(include(:id, :name)) end @@ -72,7 +72,7 @@ RSpec.describe MatchesController, type: :controller do expect(body.map { |match| match[:id] }).to match_array(stage.matches.map(&:id)) end end - # TODO: add test for upcoming once there is test data for a "valid" group stage + # TODO add test for upcoming once there is test data for a "valid" group stage end describe 'GET #show' do @@ -86,14 +86,12 @@ RSpec.describe MatchesController, type: :controller do get :show, params: { id: @match.to_param } body = deserialize_response response expect(body[:state]).to eq(@match.state) - expect(body[:match_scores].pluck(:id)).to eq(@match.match_scores.map(&:id)) + expect(body[:match_scores].map { |ms| ms[:id] }).to eq(@match.match_scores.map(&:id)) end end describe 'POST #create' do - let(:group_stage_tournament) do - create(:group_stage_tournament, group_count: 1, match_factory: :finished_group_match) - end + let(:group_stage_tournament) { create(:group_stage_tournament, group_count: 1, match_factory: :finished_group_match) } let(:group) { group_stage_tournament.stages.find_by(level: -1).groups.first } let(:team_ids) { group.teams.first(2).map(&:id) } @@ -103,8 +101,10 @@ RSpec.describe MatchesController, type: :controller do end it 'creates a decider match at the end of the group' do + log_count = group_stage_tournament.transaction_log_entries.count + expect do - post :create, params: { group_id: group.to_param, team_ids: } + post :create, params: { group_id: group.to_param, team_ids: team_ids } end.to change { group.matches.reload.count }.by(1) expect(response).to have_http_status(:created) @@ -112,6 +112,8 @@ RSpec.describe MatchesController, type: :controller do expect(created_match.decider?).to eq(true) expect(created_match.state).to eq('not_started') expect(created_match.teams.map(&:id)).to match_array(team_ids) + expect(group_stage_tournament.transaction_log_entries.count).to eq(log_count + 1) + expect(group_stage_tournament.transaction_log_entries.last.action).to eq('decider_match.created') end end @@ -122,7 +124,7 @@ RSpec.describe MatchesController, type: :controller do it 'rejects the request' do post :create, params: { group_id: group.to_param, team_ids: [group.teams.first.id] } - expect(response).to have_http_status(:unprocessable_entity) + expect(response).to have_http_status(:unprocessable_content) end end end @@ -160,8 +162,8 @@ RSpec.describe MatchesController, type: :controller do expect(last_match.reload.position).to eq(original_first_position) body = deserialize_response(response) - expect(body.pluck(:id)).to match_array([first_match.id, last_match.id]) - expect(body.pluck(:position)).to match_array([original_first_position, original_last_position]) + expect(body.map { |match| match[:id] }).to match_array([first_match.id, last_match.id]) + expect(body.map { |match| match[:position] }).to match_array([original_first_position, original_last_position]) end it 'rejects matches from different groups' do @@ -169,7 +171,7 @@ RSpec.describe MatchesController, type: :controller do patch :swap, params: { id: first_match.to_param, other_match_id: foreign_match.to_param } - expect(response).to have_http_status(:unprocessable_entity) + expect(response).to have_http_status(:unprocessable_content) expect(deserialize_response(response)[:error]).to eq('Matches must be different group matches from the same group') end @@ -194,6 +196,102 @@ RSpec.describe MatchesController, type: :controller do end end + describe 'PATCH #metadata' do + let(:group_stage_tournament) { create(:group_stage_tournament, group_count: 2, match_factory: :filled_group_match) } + + context 'as owner' do + before do + apply_authentication_headers_for group_stage_tournament.owner + allow(TournamentSyncEnqueue).to receive(:call) + allow(TournamentLiveBroadcast).to receive(:call) + end + + it 'updates selected matches by explicit ids' do + selected_matches = group_stage_tournament.matches.first(2) + + patch :metadata, params: { + tournament_id: group_stage_tournament.to_param, + match_ids: selected_matches.map(&:id), + location: 'Court A', + start_time: '17:00', + notes: 'Bring player cards' + } + + expect(response).to be_successful + expect(selected_matches.map { |match| match.reload.location }).to all(eq('Court A')) + expect(selected_matches.map { |match| match.reload.start_time }).to all(eq('17:00')) + expect(selected_matches.map { |match| match.reload.notes }).to all(eq('Bring player cards')) + expect(deserialize_response(response).map { |match| match[:id] }).to eq(selected_matches.map(&:id)) + expect(TournamentSyncEnqueue).to have_received(:call).with(group_stage_tournament) + expect(TournamentLiveBroadcast).to have_received(:call).with(group_stage_tournament.id) + end + + it 'updates group stage matches by position filter' do + patch :metadata, params: { + tournament_id: group_stage_tournament.to_param, + group_stage_position: 1, + start_time: '18:00' + } + + expect(response).to be_successful + group_stage_tournament.matches.each do |match| + expected_start_time = match.group_match? && match.position == 1 ? '18:00' : nil + expect(match.reload.start_time).to eq(expected_start_time) + end + end + + it 'updates playoff matches by stage level filter' do + playoff_tournament = create(:stage_tournament, stage_count: 3, user: group_stage_tournament.owner) + target_stage = playoff_tournament.stages.find_by!(level: 3) + + patch :metadata, params: { + tournament_id: playoff_tournament.to_param, + stage_level: target_stage.level, + start_time: '21:00' + } + + expect(response).to be_successful + playoff_tournament.matches.each do |match| + expected_start_time = match.stage == target_stage ? '21:00' : nil + expect(match.reload.start_time).to eq(expected_start_time) + end + end + + it 'requires a selector' do + patch :metadata, params: { tournament_id: group_stage_tournament.to_param, start_time: '17:00' } + + expect(response).to have_http_status(:bad_request) + expect(deserialize_response(response)[:error]).to eq('Select matches with match_ids, filters, or all=true') + end + + it 'rejects matches outside the tournament' do + other_match = create(:match) + + patch :metadata, params: { + tournament_id: group_stage_tournament.to_param, + match_ids: [other_match.id], + start_time: '17:00' + } + + expect(response).to have_http_status(:not_found) + expect(deserialize_response(response)[:error]).to eq("Matches not found in tournament: #{other_match.id}") + end + + it 'rejects read only follower tournaments' do + group_stage_tournament.update!(read_only_mode: true, sync_auth_token: 'shared-secret') + + patch :metadata, params: { + tournament_id: group_stage_tournament.to_param, + match_ids: [group_stage_tournament.matches.first.id], + start_time: '17:00' + } + + expect(response).to have_http_status(:locked) + expect(deserialize_response(response)[:error]).to eq('Tournament is in read only follower mode') + end + end + end + describe 'POST #update' do context 'on a running playoff match' do let(:valid_update) do @@ -235,6 +333,59 @@ RSpec.describe MatchesController, type: :controller do expect(body[:state]).to eq(valid_update[:state]) end + it 'updates metadata without changing match state' do + expect(TournamentLiveBroadcast).to receive(:call).with(@match.tournament.id) + + put :update, params: { + id: @match.to_param, + location: 'Main Hall', + start_time: '17:00', + notes: 'Stream setup needed' + } + + expect(response).to be_successful + @match.reload + expect(@match.state).to eq('not_started') + expect(@match.location).to eq('Main Hall') + expect(@match.start_time).to eq('17:00') + expect(@match.notes).to eq('Stream setup needed') + expect(deserialize_response(response)).to include( + location: 'Main Hall', + start_time: '17:00', + notes: 'Stream setup needed' + ) + end + + it 'writes a transaction log entry for the state change' do + expect(TournamentLiveBroadcast).to receive(:call).with(@match.tournament.id) + + expect do + put :update, params: { id: @match.to_param }.merge(valid_update) + end.to change { @match.tournament.transaction_log_entries.count }.by(1) + + entry = @match.tournament.transaction_log_entries.last + expect(entry.action).to eq('match.state_changed') + expect(entry.sequence).to eq(1) + expect(entry.message).to include("changed match #{@match.id}") + expect(entry.message).to include('running') + expect(entry.metadata['new_state']).to eq('in_progress') + end + + it 'stops an in-progress match and resets its scores' do + @match.update!(state: :in_progress) + @match.match_scores.each do |match_score| + match_score.update!(points: 7, hidden_points: 5) + end + + expect(TournamentLiveBroadcast).to receive(:call).with(@match.tournament.id) + put :update, params: { id: @match.to_param, state: 'not_started' } + + expect(response).to be_successful + expect(@match.reload.state).to eq('not_started') + expect(@match.match_scores.reload.map(&:points)).to all(eq(0)) + expect(@match.match_scores.reload.map(&:hidden_points)).to all(eq(0)) + end + context 'on a running playoff match' do let(:finished) do { @@ -290,7 +441,7 @@ RSpec.describe MatchesController, type: :controller do end it 'returns an unprocessable entity response' do - expect(response).to have_http_status(:unprocessable_entity) + expect(response).to have_http_status(:unprocessable_content) end it 'doesn\'t change the matches status' do @@ -323,7 +474,7 @@ RSpec.describe MatchesController, type: :controller do it 'returns unprocessable entity' do put :update, params: { id: @running_playoff_match.to_param }.merge(finished) - expect(response).to have_http_status(:unprocessable_entity) + expect(response).to have_http_status(:unprocessable_content) end end @@ -343,7 +494,7 @@ RSpec.describe MatchesController, type: :controller do allow_any_instance_of(Match).to receive(:winner).and_return(@running_playoff_match.match_scores.first.team) put :update, params: { id: @running_playoff_match.to_param }.merge(finished) - expect(response).to have_http_status(:unprocessable_entity) + expect(response).to have_http_status(:unprocessable_content) end end end @@ -377,14 +528,22 @@ RSpec.describe MatchesController, type: :controller do context 'with invalid params' do it 'renders an unprocessable entity response' do put :update, params: { id: @match.to_param }.merge(invalid_update) - expect(response).to have_http_status(:unprocessable_entity) + expect(response).to have_http_status(:unprocessable_content) end end context 'with senseless params' do it 'renders an unprocessable entity response' do put :update, params: { id: @match.to_param }.merge(senseless_update) - expect(response).to have_http_status(:unprocessable_entity) + expect(response).to have_http_status(:unprocessable_content) + end + end + + context 'with stop params on a not-started match' do + it 'renders an unprocessable entity response' do + put :update, params: { id: @match.to_param, state: 'not_started' } + expect(response).to have_http_status(:unprocessable_content) + expect(deserialize_response(response)[:error]).to eq("Match can't stop in this state") end end end @@ -403,6 +562,34 @@ RSpec.describe MatchesController, type: :controller do end end + context 'on a running group match' do + let(:tournament) { create(:group_stage_tournament, group_count: 1, match_factory: :filled_group_match) } + let(:group) { tournament.stages.find_by(level: -1).groups.first } + let(:group_match) { group.matches.first } + + before do + apply_authentication_headers_for tournament.owner + group_match.update!(state: :in_progress) + group_match.match_scores.order(:id).each_with_index do |match_score, index| + match_score.update!(points: index.zero? ? 9 : 2, hidden_points: index.zero? ? 4 : 1) + end + UpdateGroupsGroupScoresAndSave.call(group: group) + end + + it 'stops the match, clears scores, and recalculates group scores' do + put :update, params: { id: group_match.to_param, state: 'not_started' } + + expect(response).to be_successful + expect(group_match.reload.state).to eq('not_started') + expect(group_match.match_scores.reload.map(&:points)).to all(eq(0)) + expect(group_match.match_scores.reload.map(&:hidden_points)).to all(eq(0)) + involved_group_scores = group.group_scores.reload.select { |score| group_match.teams.include?(score.team) } + expect(involved_group_scores.map(&:group_points)).to all(eq(0)) + expect(involved_group_scores.map(&:scored_points)).to all(eq(0)) + expect(involved_group_scores.map(&:received_points)).to all(eq(0)) + end + end + context 'on a playoff match that isn\'t ready yet' do let(:invalid_update) do { @@ -418,7 +605,7 @@ RSpec.describe MatchesController, type: :controller do context 'with invalid params' do it 'renders an unprocessable entity response' do put :update, params: { id: @not_ready_playoff_match.to_param }.merge(invalid_update) - expect(response).to have_http_status(:unprocessable_entity) + expect(response).to have_http_status(:unprocessable_content) end end end diff --git a/spec/controllers/stages_controller_spec.rb b/spec/controllers/stages_controller_spec.rb index ab59bb3..d447eec 100644 --- a/spec/controllers/stages_controller_spec.rb +++ b/spec/controllers/stages_controller_spec.rb @@ -26,7 +26,7 @@ RSpec.describe StagesController, type: :controller do match.save! end - UpdateGroupsGroupScoresAndSave.call(group:) + UpdateGroupsGroupScoresAndSave.call(group: group) end stage.reload @@ -97,6 +97,13 @@ RSpec.describe StagesController, type: :controller do tournament = running_group_stage.tournament.reload expect(tournament.team_action_lists).to be_empty end + + it 'writes a transaction log entry for starting playoffs' do + entry = running_group_stage.tournament.transaction_log_entries.last + expect(entry.action).to eq('playoffs.started') + expect(entry.message).to include('started playoffs') + expect(entry.metadata['advancing_team_ids']).to match_array(GroupStageService.get_advancing_teams(running_group_stage).map(&:id)) + end end context 'as another user' do @@ -122,7 +129,7 @@ RSpec.describe StagesController, type: :controller do end it 'it returns unprocessable entity' do - expect(response).to have_http_status(:unprocessable_entity) + expect(response).to have_http_status(:unprocessable_content) end it 'returns the correct error' do @@ -145,7 +152,7 @@ RSpec.describe StagesController, type: :controller do end it 'it returns unprocessable entity' do - expect(response).to have_http_status(:unprocessable_entity) + expect(response).to have_http_status(:unprocessable_content) end it 'returns the correct error' do @@ -164,7 +171,7 @@ RSpec.describe StagesController, type: :controller do end it 'it returns unprocessable entity' do - expect(response).to have_http_status(:unprocessable_entity) + expect(response).to have_http_status(:unprocessable_content) end it 'returns the correct error' do @@ -179,20 +186,17 @@ RSpec.describe StagesController, type: :controller do before do tournament = group_stage.tournament - tournament.update!(playoff_teams_amount: 1, instant_finalists_amount: 1, - intermediate_round_participants_amount: 0) + tournament.update!(playoff_teams_amount: 1, instant_finalists_amount: 1, intermediate_round_participants_amount: 0) group = create(:group, stage: group_stage, match_count: 0, number: 1) group_stage.groups = [group] - team_a = create(:team, tournament:, name: 'A') - team_b = create(:team, tournament:, name: 'B') - create(:group_match, group:, state: :finished).tap do |match| - create(:match_score, match:, team: team_a, points: 2) - create(:match_score, match:, team: team_b, points: 2) + team_a = create(:team, tournament: tournament, name: 'A') + team_b = create(:team, tournament: tournament, name: 'B') + create(:group_match, group: group, state: :finished).tap do |match| + create(:match_score, match: match, team: team_a, points: 2) + create(:match_score, match: match, team: team_b, points: 2) end - score_a = create(:group_score, group:, team: team_a, group_points: 7, scored_points: 4, - received_points: 1) - score_b = create(:group_score, group:, team: team_b, group_points: 7, scored_points: 4, - received_points: 1) + score_a = create(:group_score, group: group, team: team_a, group_points: 7, scored_points: 4, received_points: 1) + score_b = create(:group_score, group: group, team: team_b, group_points: 7, scored_points: 4, received_points: 1) GroupStageService.send(:recalculate_position_of_group_scores!, [score_a, score_b]).each(&:save!) apply_authentication_headers_for group_stage.owner @@ -200,7 +204,7 @@ RSpec.describe StagesController, type: :controller do end it 'returns unprocessable entity' do - expect(response).to have_http_status(:unprocessable_entity) + expect(response).to have_http_status(:unprocessable_content) end it 'returns a helpful decider error' do @@ -209,6 +213,13 @@ RSpec.describe StagesController, type: :controller do expect(body[:blocking_ties].size).to eq(1) expect(body[:blocking_ties].first[:team_names]).to eq(%w[A B]) end + + it 'writes a failed playoff start transaction log entry' do + entry = group_stage.tournament.transaction_log_entries.last + expect(entry.action).to eq('playoffs.start_failed') + expect(entry.message).to include('failed because decider missing') + expect(entry.metadata['reason']).to eq('decider_missing') + end end context 'when tied advancing teams still need a decider to fix playoff seeding' do @@ -218,20 +229,17 @@ RSpec.describe StagesController, type: :controller do before do tournament = group_stage.tournament - tournament.update!(playoff_teams_amount: 2, instant_finalists_amount: 2, - intermediate_round_participants_amount: 0) + tournament.update!(playoff_teams_amount: 2, instant_finalists_amount: 2, intermediate_round_participants_amount: 0) group = create(:group, stage: group_stage, match_count: 0, number: 1) group_stage.groups = [group] - team_a = create(:team, tournament:, name: 'A') - team_b = create(:team, tournament:, name: 'B') - create(:group_match, group:, state: :finished).tap do |match| - create(:match_score, match:, team: team_a, points: 2) - create(:match_score, match:, team: team_b, points: 2) + team_a = create(:team, tournament: tournament, name: 'A') + team_b = create(:team, tournament: tournament, name: 'B') + create(:group_match, group: group, state: :finished).tap do |match| + create(:match_score, match: match, team: team_a, points: 2) + create(:match_score, match: match, team: team_b, points: 2) end - score_a = create(:group_score, group:, team: team_a, group_points: 7, scored_points: 4, - received_points: 1) - score_b = create(:group_score, group:, team: team_b, group_points: 7, scored_points: 4, - received_points: 1) + score_a = create(:group_score, group: group, team: team_a, group_points: 7, scored_points: 4, received_points: 1) + score_b = create(:group_score, group: group, team: team_b, group_points: 7, scored_points: 4, received_points: 1) GroupStageService.send(:recalculate_position_of_group_scores!, [score_a, score_b]).each(&:save!) apply_authentication_headers_for group_stage.owner @@ -239,7 +247,7 @@ RSpec.describe StagesController, type: :controller do end it 'returns unprocessable entity' do - expect(response).to have_http_status(:unprocessable_entity) + expect(response).to have_http_status(:unprocessable_content) end it 'returns a helpful decider error' do diff --git a/spec/controllers/team_action_lists_controller_spec.rb b/spec/controllers/team_action_lists_controller_spec.rb index 672d321..9a82319 100644 --- a/spec/controllers/team_action_lists_controller_spec.rb +++ b/spec/controllers/team_action_lists_controller_spec.rb @@ -47,7 +47,7 @@ RSpec.describe TeamActionListsController, type: :controller do it 'returns unprocessable entity for unsupported source' do post :create, params: create_params.merge(source: 'unknown_source') - expect(response).to have_http_status(:unprocessable_entity) + expect(response).to have_http_status(:unprocessable_content) expect(deserialize_response(response)[:error]).to eq('Unsupported team action list source: unknown_source') end @@ -57,7 +57,7 @@ RSpec.describe TeamActionListsController, type: :controller do post :create, params: create_params.merge(tournament_id: empty_tournament.to_param) - expect(response).to have_http_status(:unprocessable_entity) + expect(response).to have_http_status(:unprocessable_content) expect(deserialize_response(response)[:error]).to eq('Source all_tournament_teams does not currently contain any teams') end @@ -71,7 +71,7 @@ RSpec.describe TeamActionListsController, type: :controller do post :create, params: create_params - expect(response).to have_http_status(:unprocessable_entity) + expect(response).to have_http_status(:unprocessable_content) end end diff --git a/spec/controllers/teams_controller_spec.rb b/spec/controllers/teams_controller_spec.rb index 8dc9eb1..bd6cc19 100644 --- a/spec/controllers/teams_controller_spec.rb +++ b/spec/controllers/teams_controller_spec.rb @@ -19,6 +19,17 @@ RSpec.describe TeamsController, type: :controller do body = deserialize_response response expect(body[:name]).to eq(@team.name) end + + it 'returns player account summaries when linked' do + player = create(:user, username: 'linked-player') + @team.players << player + + get :show, params: { id: @team.to_param } + + body = deserialize_response response + expect(body[:players].first[:id]).to eq(player.id) + expect(body[:players].first[:name]).to eq('linked-player') + end end describe 'PUT #update' do @@ -46,6 +57,49 @@ RSpec.describe TeamsController, type: :controller do body = deserialize_response response expect(body[:name]).to eq(valid_update[:name]) end + + it 'associates an existing player account by email' do + player = create(:user, username: 'player-account', email: 'player@example.com') + + put :update, params: { id: @team.to_param, player_email: player.email } + + expect(response).to be_successful + expect(@team.reload.players).to contain_exactly(player) + expect(deserialize_response(response)[:players].first[:id]).to eq(player.id) + end + + it 'creates a player account by email when none exists' do + expect do + put :update, params: { id: @team.to_param, player_email: 'new-player@example.com' } + end.to change(User, :count).by(1) + + expect(response).to be_successful + expect(@team.reload.players.first.email).to eq('new-player@example.com') + expect(@team.players.first.username).to eq('new-player') + end + + it 'associates multiple player accounts by email' do + first_player = create(:user, email: 'first-player@example.com') + + put :update, params: { + id: @team.to_param, + player_emails: [first_player.email, 'second-player@example.com'] + } + + expect(response).to be_successful + expect(@team.reload.players.map(&:email)).to match_array( + ['first-player@example.com', 'second-player@example.com'] + ) + end + + it 'clears player associations with blank player_id' do + @team.players << create(:user) + + put :update, params: { id: @team.to_param, player_id: '' } + + expect(response).to be_successful + expect(@team.reload.players).to be_empty + end end context 'with valid params as another user' do @@ -58,5 +112,29 @@ RSpec.describe TeamsController, type: :controller do expect(response).to have_http_status(:forbidden) end end + + context 'with valid params as linked player' do + before(:each) do + @player = create(:user) + @team.players << @player + apply_authentication_headers_for @player + end + + it 'updates the requested team name' do + put :update, params: { id: @team.to_param }.merge(valid_update) + + expect(response).to be_successful + expect(@team.reload.name).to eq(valid_update[:name]) + end + + it 'cannot reassign the linked player' do + new_player = create(:user, email: 'other-player@example.com') + + put :update, params: { id: @team.to_param, player_email: new_player.email } + + expect(response).to have_http_status(:forbidden) + expect(@team.reload.players).to contain_exactly(@player) + end + end end end diff --git a/spec/controllers/tournament_transaction_log_entries_controller_spec.rb b/spec/controllers/tournament_transaction_log_entries_controller_spec.rb new file mode 100644 index 0000000..fe07049 --- /dev/null +++ b/spec/controllers/tournament_transaction_log_entries_controller_spec.rb @@ -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 diff --git a/spec/controllers/tournaments_controller_spec.rb b/spec/controllers/tournaments_controller_spec.rb index 4e7e0aa..8024e4c 100644 --- a/spec/controllers/tournaments_controller_spec.rb +++ b/spec/controllers/tournaments_controller_spec.rb @@ -24,7 +24,7 @@ RSpec.describe TournamentsController, type: :controller do get :index tournaments = deserialize_response response public_tournaments = tournaments.select { |t| t[:public] } - expect(public_tournaments.pluck(:id)).to match_array(Tournament.where(public: true).pluck(:id)) + expect(public_tournaments.map { |t| t[:id] }).to match_array(Tournament.where(public: true).map { |t| t[:id] }) end end @@ -35,15 +35,15 @@ RSpec.describe TournamentsController, type: :controller do it 'returns all private tournaments' do apply_authentication_headers_for @another_user - get(:index, params:) + get :index, params: params tournaments = deserialize_response response - private_tournaments = Tournament.where(owner: @another_user, public: false).pluck(:id) - returned_private_tournaments = tournaments.filter { |t| !t[:public] }.pluck(:id) + private_tournaments = Tournament.where(owner: @another_user, public: false).map { |t| t[:id] } + returned_private_tournaments = tournaments.filter { |t| !t[:public] }.map { |t| t[:id] } expect(returned_private_tournaments).to match_array(private_tournaments) end it 'returns no private tournaments for unauthenticated users' do - get(:index, params:) + get :index, params: params tournaments = deserialize_response response private_tournaments = tournaments.reject { |t| t[:public] } expect(private_tournaments.size).to eq(0) @@ -51,14 +51,14 @@ RSpec.describe TournamentsController, type: :controller do it 'returns no private tournaments owned by another user' do apply_authentication_headers_for @user - get(:index, params:) + get :index, params: params tournaments = deserialize_response response - expect(tournaments.pluck(:id)).not_to include(@private_tournament.id) + expect(tournaments.map { |t| t[:id] }).not_to include(@private_tournament.id) end it 'returns no public tournaments' do apply_authentication_headers_for @another_user - get(:index, params:) + get :index, params: params tournaments = deserialize_response response expect(tournaments.count { |t| t[:public] }).to eq(0) end @@ -70,15 +70,15 @@ RSpec.describe TournamentsController, type: :controller do end it 'returns all public tournaments' do - get(:index, params:) + get :index, params: params tournaments = deserialize_response response public_tournaments = tournaments.select { |t| t[:public] } - expect(public_tournaments.pluck(:id)).to match_array(Tournament.where(public: true).pluck(:id)) + expect(public_tournaments.map { |t| t[:id] }).to match_array(Tournament.where(public: true).map { |t| t[:id] }) end it 'returns no private tournaments' do apply_authentication_headers_for @another_user - get(:index, params:) + get :index, params: params tournaments = deserialize_response response expect(tournaments.count { |t| !t[:public] }).to eq(0) end @@ -105,7 +105,8 @@ RSpec.describe TournamentsController, type: :controller do expected_keys = %i[ id name code public description playoff_teams_amount instant_finalists_amount intermediate_round_participants_amount - timestamp timer_mode owner_username stages teams team_action_lists + location starts_at ends_at website_url + timestamp timer_mode timer_reason timer_reason_text owner_username stages teams team_action_lists ] expect(json.keys).to match_array(expected_keys) expect(json[:name]).to eq(@tournament.name) @@ -150,9 +151,11 @@ RSpec.describe TournamentsController, type: :controller do json = deserialize_response(response) expect(json[:read_only_mode]).to eq(true) expect(json[:sync_target_url]).to eq('https://remote.example.com/tournaments/1/sync_state') + expect(json[:sync_auth_configured]).to eq(true) expect(json[:sync_last_pushed_at]).to eq(pushed_at.iso8601) expect(json[:sync_last_push_error]).to eq('push failed') expect(json).not_to have_key(:sync_auth_token) + expect(json).not_to have_key(:sync_auth_token_digest) expect(json).to eq(TournamentSerializer.new(@tournament, scope: @tournament.owner).as_json) end @@ -224,13 +227,20 @@ RSpec.describe TournamentsController, type: :controller do describe 'GET #timer' do it 'returns timer state in ISO 8601 format' do timestamp = Time.utc(2026, 4, 13, 18, 0, 0) - @tournament.update!(timestamp:, timer_mode: 'countdown') + @tournament.update!( + timestamp: timestamp, + timer_mode: 'countdown', + timer_reason: 'custom', + timer_reason_text: 'Opening ceremony' + ) get :timer, params: { id: @tournament.to_param } expect(response).to be_successful expect(deserialize_response(response)[:timestamp]).to eq(timestamp.iso8601) expect(deserialize_response(response)[:timer_mode]).to eq('countdown') + expect(deserialize_response(response)[:timer_reason]).to eq('custom') + expect(deserialize_response(response)[:timer_reason_text]).to eq('Opening ceremony') end end @@ -242,43 +252,143 @@ RSpec.describe TournamentsController, type: :controller do it 'stores timezone-aware timestamp values without shifting them' do timestamp = '2030-04-13T18:00:00+02:00' - patch :set_timer, params: { id: @tournament.to_param, timestamp: } + patch :set_timer, params: { + id: @tournament.to_param, + timestamp: timestamp, + timer_reason: 'tournament_start' + } expect(response).to be_successful expect(@tournament.reload.timestamp.iso8601).to eq(Time.iso8601(timestamp).utc.iso8601) + expect(@tournament.timer_reason).to eq('tournament_start') + expect(@tournament.timer_reason_text).to be_nil expect(deserialize_response(response)[:timestamp]).to eq(@tournament.timestamp.iso8601) expect(deserialize_response(response)[:timer_mode]).to eq('countdown') + expect(deserialize_response(response)[:timer_reason]).to eq('tournament_start') end it 'starts countup timer at current time when timer_mode=countup' do before_request = Time.zone.now - patch :set_timer, params: { id: @tournament.to_param, timer_mode: 'countup' } + patch :set_timer, params: { + id: @tournament.to_param, + timer_mode: 'countup', + timer_reason: 'custom', + timer_reason_text: 'Tie-break delay' + } expect(response).to be_successful expect(@tournament.reload.timer_mode).to eq('countup') - expect(@tournament.timestamp).to be_between(before_request - 1.second, 1.second.from_now) + expect(@tournament.timer_reason).to eq('custom') + expect(@tournament.timer_reason_text).to eq('Tie-break delay') + expect(@tournament.timestamp).to be_between(before_request - 1.second, Time.zone.now + 1.second) expect(deserialize_response(response)[:timer_mode]).to eq('countup') + expect(deserialize_response(response)[:timer_reason]).to eq('custom') + expect(deserialize_response(response)[:timer_reason_text]).to eq('Tie-break delay') end it 'clears timer state when clear_timer=true' do - @tournament.update!(timestamp: Time.utc(2030, 4, 13, 18, 0, 0), timer_mode: 'countdown') + @tournament.update!( + timestamp: Time.utc(2030, 4, 13, 18, 0, 0), + timer_mode: 'countdown', + timer_reason: 'custom', + timer_reason_text: 'Warm-up pause' + ) patch :set_timer, params: { id: @tournament.to_param, clear_timer: true } expect(response).to be_successful expect(@tournament.reload.timestamp).to be_nil expect(@tournament.timer_mode).to be_nil + expect(@tournament.timer_reason).to be_nil + expect(@tournament.timer_reason_text).to be_nil expect(deserialize_response(response)[:timestamp]).to be_nil expect(deserialize_response(response)[:timer_mode]).to be_nil + expect(deserialize_response(response)[:timer_reason]).to be_nil + expect(deserialize_response(response)[:timer_reason_text]).to be_nil + end + + it 'updates timer reason without changing active timer clock' do + original_timestamp = Time.utc(2030, 4, 13, 18, 0, 0) + @tournament.update!( + timestamp: original_timestamp, + timer_mode: 'countdown', + timer_reason: 'tournament_start' + ) + + patch :set_timer, params: { + id: @tournament.to_param, + timer_reason: 'custom', + timer_reason_text: 'Schedule pause' + } + + expect(response).to be_successful + expect(@tournament.reload.timestamp).to eq(original_timestamp) + expect(@tournament.timer_mode).to eq('countdown') + expect(@tournament.timer_reason).to eq('custom') + expect(@tournament.timer_reason_text).to eq('Schedule pause') + expect(deserialize_response(response)[:timestamp]).to eq(original_timestamp.iso8601) + expect(deserialize_response(response)[:timer_mode]).to eq('countdown') + expect(deserialize_response(response)[:timer_reason]).to eq('custom') + expect(deserialize_response(response)[:timer_reason_text]).to eq('Schedule pause') + + patch :set_timer, params: { + id: @tournament.to_param, + timer_reason: '' + } + + expect(response).to be_successful + expect(@tournament.reload.timestamp).to eq(original_timestamp) + expect(@tournament.timer_mode).to eq('countdown') + expect(@tournament.timer_reason).to be_nil + expect(@tournament.timer_reason_text).to be_nil + expect(deserialize_response(response)[:timestamp]).to eq(original_timestamp.iso8601) + expect(deserialize_response(response)[:timer_mode]).to eq('countdown') + expect(deserialize_response(response)[:timer_reason]).to be_nil + expect(deserialize_response(response)[:timer_reason_text]).to be_nil + end + + it 'rejects timer reason updates when no timer is active' do + patch :set_timer, params: { id: @tournament.to_param, timer_reason: 'match_end' } + + expect(response).to have_http_status(:unprocessable_content) + expect(deserialize_response(response)[:error]).to eq('timer_reason requires active timer') end it 'rejects timestamp_seconds for countup mode' do patch :set_timer, params: { id: @tournament.to_param, timer_mode: 'countup', timestamp_seconds: 120 } - expect(response).to have_http_status(:unprocessable_entity) + expect(response).to have_http_status(:unprocessable_content) expect(deserialize_response(response)[:error]).to eq('timestamp_seconds is only allowed for countdown mode') end + + it 'rejects invalid timer reason values' do + patch :set_timer, params: { id: @tournament.to_param, timestamp_seconds: 120, timer_reason: 'ceremony' } + + expect(response).to have_http_status(:unprocessable_content) + expect(deserialize_response(response)[:error]).to eq('Invalid timer_reason') + end + + it 'requires timer_reason_text for custom timer reason' do + patch :set_timer, params: { id: @tournament.to_param, timestamp_seconds: 120, timer_reason: 'custom' } + + expect(response).to have_http_status(:unprocessable_content) + expect(deserialize_response(response)[:error]).to eq('timer_reason_text is required for custom timer_reason') + end + + it 'rejects timer_reason_text for predefined timer reasons' do + patch :set_timer, params: { + id: @tournament.to_param, + timestamp_seconds: 120, + timer_reason: 'match_end', + timer_reason_text: 'Extra info' + } + + expect(response).to have_http_status(:unprocessable_content) + expect(deserialize_response(response)[:error]).to eq( + 'timer_reason_text is only allowed when timer_reason is custom' + ) + end end describe 'POST #create' do @@ -329,8 +439,12 @@ RSpec.describe TournamentsController, type: :controller do expect(response).to have_http_status(:created) tournament = Tournament.find(deserialize_response(response)[:id]) expect(tournament.read_only_mode?).to eq(true) + expect(tournament.sync_auth_token).to be_nil + expect(tournament.sync_auth_token_digest).to be_present expect(tournament.teams).to be_empty expect(tournament.stages).to be_empty + expect(deserialize_response(response)).not_to have_key(:sync_auth_token) + expect(deserialize_response(response)).not_to have_key(:sync_auth_token_digest) end end @@ -341,6 +455,25 @@ RSpec.describe TournamentsController, type: :controller do end.to change(Tournament, :count).by(1) end + it 'stores tournament metadata' do + starts_at = '2030-06-01T10:00:00+02:00' + ends_at = '2030-06-01T18:00:00+02:00' + + post :create, params: create_playoff_tournament_data.merge( + location: 'Stadtpark Halle', + starts_at: starts_at, + ends_at: ends_at, + website_url: 'https://example.com/tournaments/summer-cup' + ) + + expect(response).to have_http_status(:created) + tournament = Tournament.find(deserialize_response(response)[:id]) + expect(tournament.location).to eq('Stadtpark Halle') + expect(tournament.starts_at.iso8601).to eq(Time.iso8601(starts_at).utc.iso8601) + expect(tournament.ends_at.iso8601).to eq(Time.iso8601(ends_at).utc.iso8601) + expect(tournament.website_url).to eq('https://example.com/tournaments/summer-cup') + end + it 'associates the new tournament with the authenticated user' do expect do post :create, params: create_playoff_tournament_data @@ -406,7 +539,7 @@ RSpec.describe TournamentsController, type: :controller do context 'playoff_teams_amount unacceptable' do shared_examples_for 'wrong playoff_teams_amount' do it 'fails' do - expect(response).to have_http_status(:unprocessable_entity) + expect(response).to have_http_status(:unprocessable_content) end it 'returns the correct error message' do expect(deserialize_response(response)[:playoff_teams_amount].first) @@ -462,7 +595,7 @@ RSpec.describe TournamentsController, type: :controller do data = create_group_tournament_data data[:teams].pop post :create, params: data - expect(response).to have_http_status(:unprocessable_entity) + expect(response).to have_http_status(:unprocessable_content) end end @@ -475,12 +608,35 @@ RSpec.describe TournamentsController, type: :controller do post :create, params: data end.to change(Team, :count).by(data[:teams].count) end + + it 'creates player accounts from team email values' do + data = create_playoff_tournament_data + data.delete :teams + data[:teams] = [ + { + name: 'Alpha', + player_emails: ['alpha-player@example.com', 'alpha-partner@example.com'] + }, + { name: 'Beta' }, + { name: 'Gamma' }, + { name: 'Delta' } + ] + + expect do + post :create, params: data + end.to change(User, :count).by(2) + + tournament = Tournament.find(deserialize_response(response)[:id]) + expect(tournament.teams.find_by(name: 'Alpha').players.map(&:email)) + .to match_array(['alpha-player@example.com', 'alpha-partner@example.com']) + expect(tournament.teams.find_by(name: 'Beta').players).to be_empty + end end context 'with invalid parameters' do it 'renders an unprocessable entity response' do put :create, params: { teams: [1, 2, 3] } - expect(response).to have_http_status(:unprocessable_entity) + expect(response).to have_http_status(:unprocessable_content) end end @@ -489,7 +645,7 @@ RSpec.describe TournamentsController, type: :controller do data = create_group_tournament_data data[:teams] = [{ group: 1 }, { group: 1 }, { group: 2 }, { group: 2 }] post :create, params: data - expect(response).to have_http_status(:unprocessable_entity) + expect(response).to have_http_status(:unprocessable_content) end end end @@ -521,6 +677,43 @@ RSpec.describe TournamentsController, type: :controller do expect(@tournament.name).to eq(valid_update[:name]) end + it 'updates tournament metadata' do + starts_at = '2030-06-01T10:00:00+02:00' + ends_at = '2030-06-01T18:00:00+02:00' + + put :update, params: { + id: @tournament.to_param, + location: 'Arena Nord', + starts_at: starts_at, + ends_at: ends_at, + website_url: 'https://example.com/events/arena-nord' + } + + expect(response).to have_http_status(:ok) + @tournament.reload + json = deserialize_response(response) + expect(@tournament.location).to eq('Arena Nord') + expect(@tournament.starts_at.iso8601).to eq(Time.iso8601(starts_at).utc.iso8601) + expect(@tournament.ends_at.iso8601).to eq(Time.iso8601(ends_at).utc.iso8601) + expect(@tournament.website_url).to eq('https://example.com/events/arena-nord') + expect(json[:starts_at]).to eq(@tournament.starts_at.iso8601) + expect(json[:ends_at]).to eq(@tournament.ends_at.iso8601) + end + + it 'rejects invalid tournament metadata' do + put :update, params: { + id: @tournament.to_param, + starts_at: '2030-06-01T18:00:00+02:00', + ends_at: '2030-06-01T10:00:00+02:00', + website_url: 'notaurl' + } + + expect(response).to have_http_status(:unprocessable_content) + json = deserialize_response(response) + expect(json[:ends_at]).to include('must be after starts_at') + expect(json[:website_url]).to include('is invalid') + end + it 'renders a JSON response with the tournament' do put :update, params: { id: @tournament.to_param }.merge(valid_update) expect(response).to have_http_status(:ok) @@ -542,6 +735,9 @@ RSpec.describe TournamentsController, type: :controller do expect(@tournament.read_only_mode?).to eq(false) expect(@tournament.sync_target_url).to include('/sync_state') expect(@tournament.sync_auth_token).to eq('new-token') + expect(@tournament.sync_auth_token_digest).to be_nil + expect(deserialize_response(response)).not_to have_key(:sync_auth_token) + expect(deserialize_response(response)).not_to have_key(:sync_auth_token_digest) end it 'allows follower takeover when only read_only_mode is disabled' do @@ -558,6 +754,7 @@ RSpec.describe TournamentsController, type: :controller do expect(@tournament.read_only_mode?).to eq(false) expect(@tournament.sync_target_url).to be_blank expect(@tournament.sync_auth_token).to be_blank + expect(@tournament.sync_auth_token_digest).to be_blank end it 'blocks normal updates while tournament is read only' do @@ -577,18 +774,18 @@ RSpec.describe TournamentsController, type: :controller do it 'fails when only instant_finalists_amount is changed' do put :update, params: { id: @filled_tournament.to_param }.merge(instant_finalists_amount: 29) - expect(response).to have_http_status(:unprocessable_entity) + expect(response).to have_http_status(:unprocessable_content) end it 'fails when only intermediate_round_participants_amount is changed' do put :update, params: { id: @filled_tournament.to_param }.merge(intermediate_round_participants_amount: 29) - expect(response).to have_http_status(:unprocessable_entity) + expect(response).to have_http_status(:unprocessable_content) end it 'fails when parameters don\'t match' do put :update, params: { id: @filled_tournament.to_param }.merge(intermediate_round_participants_amount: 29, instant_finalists_amount: 32) - expect(response).to have_http_status(:unprocessable_entity) + expect(response).to have_http_status(:unprocessable_content) end it 'succeeds when all three are changed correctly' do @@ -607,7 +804,7 @@ RSpec.describe TournamentsController, type: :controller do it 'returns unprocessable entity' do put :update, params: { id: @filled_tournament.to_param }.merge(playoff_teams_amount: 8) - expect(response).to have_http_status(:unprocessable_entity) + expect(response).to have_http_status(:unprocessable_content) end it 'doesn\'t change playoff_teams_amount' do @@ -660,7 +857,7 @@ RSpec.describe TournamentsController, type: :controller do it 'fails when playoff_teams_amount is higher than the amount of teams participating' do put :update, params: { id: @filled_tournament.to_param }.merge(playoff_teams_amount: 783) - expect(response).to have_http_status(:unprocessable_entity) + expect(response).to have_http_status(:unprocessable_content) end end end @@ -724,6 +921,119 @@ RSpec.describe TournamentsController, type: :controller do end end + describe 'DELETE #destroy_playoffs' do + context 'without authentication headers' do + it 'renders an unauthorized error response' do + tournament = create_group_stage_with_unplayed_playoffs + + delete :destroy_playoffs, params: { id: tournament.to_param } + + expect(response).to have_http_status(:unauthorized) + end + end + + context 'as owner' do + before do + apply_authentication_headers_for tournament.owner + allow(TournamentLiveBroadcast).to receive(:call) + end + + let(:tournament) { create_group_stage_with_unplayed_playoffs } + + it 'deletes unplayed playoff stages and reopens the group stage' do + expect do + delete :destroy_playoffs, params: { id: tournament.to_param } + end.to change { tournament.reload.stages.where('level >= 0').count }.from(1).to(0) + + expect(response).to have_http_status(:ok) + expect(tournament.group_stage.reload).to be_in_progress + expect(deserialize_response(response).fetch(:stages).map { |stage| stage.fetch(:level) }).to eq([-1]) + end + + it 'pushes sync and live updates after deletion' do + expect(TournamentSyncEnqueue).to receive(:call).with(tournament) + expect(TournamentLiveBroadcast).to receive(:call).with(tournament.id) + + delete :destroy_playoffs, params: { id: tournament.to_param } + + expect(response).to have_http_status(:ok) + end + + it 'rejects deletion after a playoff match has started' do + playoff_match = tournament.stages.find { |stage| stage.level >= 0 }.matches.first + playoff_match.update!(state: :in_progress) + + expect do + delete :destroy_playoffs, params: { id: tournament.to_param } + end.not_to(change { tournament.reload.stages.where('level >= 0').count }) + + expect(response).to have_http_status(:unprocessable_content) + expect(deserialize_response(response)[:error]).to eq( + 'Playoffs cannot be deleted after matches have started or scores changed' + ) + expect(tournament.group_stage.reload).to be_finished + end + + it 'rejects deletion after a playoff score has changed' do + playoff_match = tournament.stages.find { |stage| stage.level >= 0 }.matches.first + playoff_match.match_scores.first.update!(points: 1) + + delete :destroy_playoffs, params: { id: tournament.to_param } + + expect(response).to have_http_status(:unprocessable_content) + expect(deserialize_response(response)[:error]).to eq( + 'Playoffs cannot be deleted after matches have started or scores changed' + ) + expect(tournament.reload.stages.where('level >= 0').count).to eq(1) + end + + it 'rejects deletion when no group stage exists' do + playoff_only = create(:stage_tournament, stage_count: 1) + apply_authentication_headers_for playoff_only.owner + + delete :destroy_playoffs, params: { id: playoff_only.to_param } + + expect(response).to have_http_status(:unprocessable_content) + expect(deserialize_response(response)[:error]).to eq('Only group-stage playoffs can be deleted') + end + + it 'blocks deletion while tournament is read only' do + tournament.update!(read_only_mode: true, sync_auth_token: 'shared-secret') + + delete :destroy_playoffs, params: { id: tournament.to_param } + + expect(response).to have_http_status(:locked) + end + end + + context 'as another user' do + it 'renders a forbidden error response' do + tournament = create_group_stage_with_unplayed_playoffs + apply_authentication_headers_for create(:user) + + delete :destroy_playoffs, params: { id: tournament.to_param } + + expect(response).to have_http_status(:forbidden) + end + end + + def create_group_stage_with_unplayed_playoffs + tournament = create( + :group_stage_tournament, + stage_count: 0, + group_count: 2, + teams_count: 4, + playoff_teams_amount: 2, + instant_finalists_amount: 2, + intermediate_round_participants_amount: 0 + ) + tournament.group_stage.update!(state: :finished) + result = AddPlayoffsToTournamentAndSave.call(tournament: tournament, teams: tournament.teams.take(2)) + expect(result).to be_a_success + tournament.reload + end + end + describe 'PATCH #sync_state' do before do @tournament.update!(read_only_mode: true, sync_auth_token: 'shared-secret') @@ -740,6 +1050,10 @@ RSpec.describe TournamentsController, type: :controller do code: 'abc123', description: 'Synced description', public: true, + location: 'Synced Arena', + starts_at: '2030-06-01T10:00:00+02:00', + ends_at: '2030-06-01T18:00:00+02:00', + website_url: 'https://example.com/synced', playoff_teams_amount: 4, instant_finalists_amount: 4, intermediate_round_participants_amount: 0, @@ -757,7 +1071,13 @@ RSpec.describe TournamentsController, type: :controller do @tournament.reload expect(@tournament.sync_source_tournament_id).to eq(123) expect(@tournament.name).to eq('Synced Tournament') + expect(@tournament.location).to eq('Synced Arena') + expect(@tournament.starts_at.iso8601).to eq(Time.iso8601('2030-06-01T10:00:00+02:00').utc.iso8601) + expect(@tournament.ends_at.iso8601).to eq(Time.iso8601('2030-06-01T18:00:00+02:00').utc.iso8601) + expect(@tournament.website_url).to eq('https://example.com/synced') expect(@tournament.teams.pluck(:name)).to eq(['Alpha']) + expect(deserialize_response(response)).not_to have_key(:sync_auth_token) + expect(deserialize_response(response)).not_to have_key(:sync_auth_token_digest) end it 'rejects invalid tokens' do @@ -797,13 +1117,16 @@ RSpec.describe TournamentsController, type: :controller do expect(json[:id]).to eq(@tournament.id) expect(json[:sync_last_pushed_at]).to eq(pushed_at.iso8601) expect(json[:sync_last_push_error]).to be_nil + expect(json[:sync_auth_configured]).to eq(true) + expect(json).not_to have_key(:sync_auth_token) + expect(json).not_to have_key(:sync_auth_token_digest) expect(TournamentSyncPusher).to have_received(:push!).with(@tournament) end it 'rejects sync test without complete leader configuration' do post :test_sync, params: { id: @tournament.to_param } - expect(response).to have_http_status(:unprocessable_entity) + expect(response).to have_http_status(:unprocessable_content) expect(deserialize_response(response)[:error]) .to eq('Tournament sync test requires writable tournament with sync_target_url and sync_auth_token') end @@ -828,4 +1151,54 @@ RSpec.describe TournamentsController, type: :controller do expect(json.dig(:tournament, :sync_last_push_error)).to eq('connection refused') end end + + describe 'PATCH #start_next_group_matches' do + let(:tournament) { create(:group_stage_tournament, group_count: 4, match_factory: :filled_group_match) } + let(:groups) { tournament.group_stage.groups.sort_by(&:number) } + + before do + set_group_match_states(groups[0], %i[finished finished finished in_progress]) + groups.drop(1).each do |group| + set_group_match_states(group, %i[finished finished in_progress not_started]) + end + end + + context 'as owner' do + before do + apply_authentication_headers_for tournament.owner + end + + it 'starts only lowest pending catch-up matches and returns them' do + expect(TournamentLiveBroadcast).to receive(:call).with(tournament.id) + + patch :start_next_group_matches, params: { id: tournament.to_param } + + expect(response).to have_http_status(:ok) + body = deserialize_response(response) + expect(body.map { |match| match.dig(:group, :id) }).to match_array(groups.drop(1).map(&:id)) + expect(body.map { |match| match[:position] }.uniq).to eq([3]) + expect(body.map { |match| match[:state] }.uniq).to eq(['in_progress']) + expect(groups.drop(1).map { |group| group.matches.find_by!(position: 3).reload.state }.uniq) + .to eq(['in_progress']) + end + end + + context 'as another user' do + before do + apply_authentication_headers_for create(:user) + end + + it 'renders a forbidden error response' do + patch :start_next_group_matches, params: { id: tournament.to_param } + + expect(response).to have_http_status(:forbidden) + end + end + + def set_group_match_states(group, states) + group.matches.order(:id).each_with_index do |match, index| + match.update!(position: index, state: states.fetch(index)) + end + end + end end diff --git a/spec/e2e/http/api_surface_spec.rb b/spec/e2e/http/api_surface_spec.rb index a90e44a..258be34 100644 --- a/spec/e2e/http/api_surface_spec.rb +++ b/spec/e2e/http/api_surface_spec.rb @@ -20,7 +20,7 @@ RSpec.describe 'Backend API surface HTTP E2E' do let(:owner_client) { login_client(email: owner_email, password: owner_password) } let(:other_client) { secondary_client } - let(:anonymous_client) { TurniereE2E::ApiClient.new(base_url:) } + let(:anonymous_client) { TurniereE2E::ApiClient.new(base_url: base_url) } it 'reports health' do response = anonymous_client.get('/healthz') @@ -33,7 +33,9 @@ RSpec.describe 'Backend API surface HTTP E2E' do response = anonymous_client.get('/version') expect(response[:status]).to eq(200) - expect(response.dig(:json, :git_commit_sha)).to match(/\A[0-9a-f]{40}\z/) + sha = response.dig(:json, :git_commit_sha) + expect(sha).to match(/\A[0-9a-f]{40}\z/) + expect(sha).not_to eq("aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa") end it 'supports registration for new users and still requires confirmation for login' do @@ -41,16 +43,16 @@ RSpec.describe 'Backend API surface HTTP E2E' do email = "http-e2e-#{unique}@example.com" password = 'password123' username = "http-e2e-#{unique}" - client = TurniereE2E::ApiClient.new(base_url:) + client = TurniereE2E::ApiClient.new(base_url: base_url) registration = client.post('/users', body: { - email:, - password:, + email: email, + password: password, password_confirmation: password, - username: + username: username }) - login = TurniereE2E::ApiClient.new(base_url:).login!(email:, password:) + login = TurniereE2E::ApiClient.new(base_url: base_url).login!(email: email, password: password) - expect([500, *200..299]).to include(registration[:status]) + expect(registration[:status]).to be_between(200, 299) expect(registration[:json]).not_to eq({}) expect(login[:status]).to eq(401) end @@ -66,12 +68,12 @@ RSpec.describe 'Backend API surface HTTP E2E' do invalid_index = anonymous_client.get('/tournaments', params: { type: 'bogus' }) expect(public_index[:status]).to eq(200) - expect(public_index[:json].pluck(:id)).to include(public_tournament.fetch(:id)) - expect(public_index[:json].pluck(:id)).not_to include(private_tournament.fetch(:id)) + expect(public_index[:json].map { |tournament| tournament[:id] }).to include(public_tournament.fetch(:id)) + expect(public_index[:json].map { |tournament| tournament[:id] }).not_to include(private_tournament.fetch(:id)) expect(private_index[:status]).to eq(200) - expect(private_index[:json].pluck(:id)).to include(private_tournament.fetch(:id)) - expect(private_index[:json].pluck(:id)).not_to include(public_tournament.fetch(:id)) + expect(private_index[:json].map { |tournament| tournament[:id] }).to include(private_tournament.fetch(:id)) + expect(private_index[:json].map { |tournament| tournament[:id] }).not_to include(public_tournament.fetch(:id)) expect(simple_show[:status]).to eq(200) expect(simple_show[:json][:id]).to eq(public_tournament.fetch(:id)) @@ -80,6 +82,10 @@ RSpec.describe 'Backend API surface HTTP E2E' do expect(full_show[:status]).to eq(200) expect(full_show.dig(:json, :stages)).to be_an(Array) expect(full_show.dig(:json, :teams)).to be_an(Array) + expect(full_show.dig(:json, :location)).to start_with('Venue') + expect(full_show.dig(:json, :starts_at)).to eq('2030-06-01T08:00:00Z') + expect(full_show.dig(:json, :ends_at)).to eq('2030-06-01T16:00:00Z') + expect(full_show.dig(:json, :website_url)).to eq('https://example.com/turniere-e2e') expect(invalid_index[:status]).to eq(400) expect(invalid_index.dig(:json, :error)).to eq('invalid type') @@ -100,10 +106,10 @@ RSpec.describe 'Backend API surface HTTP E2E' do teams: [{ name: 'Solo Team' }] }) - expect(group_stage.fetch(:stages).pluck(:level)).to include(-1) + expect(group_stage.fetch(:stages).map { |stage| stage[:level] }).to include(-1) expect(group_stage.fetch(:teams).count).to eq(16) - expect(playoff_only.fetch(:stages).pluck(:level).sort).to eq([0, 1]) + expect(playoff_only.fetch(:stages).map { |stage| stage[:level] }.sort).to eq([0, 1]) expect(playoff_only.fetch(:teams).count).to eq(4) expect(invalid_teams[:status]).to eq(422) @@ -112,11 +118,79 @@ RSpec.describe 'Backend API surface HTTP E2E' do expect(unauthenticated[:status]).to eq(401) end + it 'links player accounts to teams and exposes public account profiles' do + unless ENV['TURNIERE_E2E_ALT_EMAIL'] && ENV['TURNIERE_E2E_ALT_USERNAME'] + skip('player profile flow requires TURNIERE_E2E_ALT_EMAIL and TURNIERE_E2E_ALT_USERNAME') + end + + unique = unique_suffix + player_email = ENV.fetch('TURNIERE_E2E_ALT_EMAIL') + player_username = ENV.fetch('TURNIERE_E2E_ALT_USERNAME') + player_client = other_client + player_created_tournament = create_playoff_tournament( + client: player_client, + public: true, + name_prefix: 'Player Created' + ) + tournament = create_playoff_tournament(client: owner_client, public: true, name_prefix: 'Player Profile') + linked_team = tournament.fetch(:teams).first + unlinked_team = tournament.fetch(:teams).fetch(1) + + link_existing = owner_client.patch("/teams/#{linked_team.fetch(:id)}", body: { player_email: player_email }) + player_update = player_client.patch("/teams/#{linked_team.fetch(:id)}", body: { + name: "#{linked_team.fetch(:name)} Player Rename" + }) + player_reassign = player_client.patch("/teams/#{linked_team.fetch(:id)}", body: { + player_email: "stolen-#{unique}@example.com" + }) + co_player_email = "http-e2e-co-player-#{unique}@example.com" + link_co_player = owner_client.patch("/teams/#{linked_team.fetch(:id)}", body: { + player_email: co_player_email + }) + placeholder_email = "http-e2e-placeholder-#{unique}@example.com" + link_placeholder = owner_client.patch("/teams/#{unlinked_team.fetch(:id)}", body: { + player_email: placeholder_email + }) + profile = anonymous_client.get("/accounts/#{player_id_by_name(link_existing, player_username)}") + placeholder_profile = anonymous_client.get("/accounts/#{player_id_by_name(link_placeholder, placeholder_email)}") + + expect(player_client.authenticated?).to eq(true) + expect(linked_team[:players]).to eq([]) + + expect(link_existing[:status]).to eq(200) + expect(link_existing.dig(:json, :players).map { |player| player.fetch(:name) }).to eq([player_username]) + + expect(player_update[:status]).to eq(200) + expect(player_update.dig(:json, :name)).to end_with('Player Rename') + expect(player_reassign[:status]).to eq(403) + + expect(link_co_player[:status]).to eq(200) + co_player_names = link_co_player.dig(:json, :players).map { |player| player.fetch(:name) } + expect(co_player_names).to include(player_username) + expect(co_player_names.any? { |name| name.start_with?('http-e2e-co-player') }).to eq(true) + + expect(link_placeholder[:status]).to eq(200) + expect(link_placeholder.dig(:json, :players).first[:name]).to start_with('http-e2e-placeholder') + expect(placeholder_profile[:status]).to eq(200) + expect(placeholder_profile.dig(:json, :teams).map { |team| team.fetch(:id) }).to include(unlinked_team.fetch(:id)) + + expect(profile[:status]).to eq(200) + expect(profile.dig(:json, :name)).to eq(player_username) + expect(profile.dig(:json, :teams).map { |team| team.fetch(:id) }).to include(linked_team.fetch(:id)) + expect(profile.dig(:json, :tournaments).map { |item| item.fetch(:id) }).to include(tournament.fetch(:id)) + expect(profile.dig(:json, :created_tournaments).map { |item| item.fetch(:id) }) + .to include(player_created_tournament.fetch(:id)) + end + it 'updates and destroys tournaments, enforces owner checks, and validates timer updates' do tournament = create_group_stage_tournament(client: owner_client, public: false, name_prefix: 'Mutable Tournament') update_response = owner_client.patch("/tournaments/#{tournament.fetch(:id)}", body: { name: "Renamed #{unique_suffix}", + location: 'Updated E2E Arena', + starts_at: '2030-07-01T10:00:00+02:00', + ends_at: '2030-07-01T18:00:00+02:00', + website_url: 'https://example.com/updated-turniere-e2e', playoff_teams_amount: 8, instant_finalists_amount: 8, intermediate_round_participants_amount: 0 @@ -132,16 +206,39 @@ RSpec.describe 'Backend API surface HTTP E2E' do forbidden_update = other_client.patch("/tournaments/#{tournament.fetch(:id)}", body: { name: 'Not Allowed' }) unauthenticated_update = anonymous_client.patch("/tournaments/#{tournament.fetch(:id)}", body: { name: 'No Auth' }) - timer_seconds = owner_client.patch("/tournaments/#{tournament.fetch(:id)}/timer", body: { timestamp_seconds: 120 }) + timer_seconds = owner_client.patch("/tournaments/#{tournament.fetch(:id)}/timer", body: { + timestamp_seconds: 120, + timer_reason: 'tournament_start' + }) timer_show = anonymous_client.get("/tournaments/#{tournament.fetch(:id)}/timer") - timer_countup = owner_client.patch("/tournaments/#{tournament.fetch(:id)}/timer", body: { timer_mode: 'countup' }) + timer_reason_custom = owner_client.patch("/tournaments/#{tournament.fetch(:id)}/timer", body: { + timer_reason: 'custom', + timer_reason_text: 'Pause ran long' + }) + timer_reason_remove = owner_client.patch("/tournaments/#{tournament.fetch(:id)}/timer", body: { + timer_reason: nil + }) + timer_countup = owner_client.patch("/tournaments/#{tournament.fetch(:id)}/timer", body: { + timer_mode: 'countup', + timer_reason: 'custom', + timer_reason_text: 'Pause ran long' + }) timer_clear = owner_client.patch("/tournaments/#{tournament.fetch(:id)}/timer", body: { clear_timer: true }) timer_double = owner_client.patch("/tournaments/#{tournament.fetch(:id)}/timer", body: { timestamp_seconds: 120, timestamp: (Time.now.utc + 240).iso8601 }) - timer_invalid_seconds = owner_client.patch("/tournaments/#{tournament.fetch(:id)}/timer", - body: { timestamp_seconds: 'nope' }) + timer_invalid_reason = owner_client.patch("/tournaments/#{tournament.fetch(:id)}/timer", body: { + timestamp_seconds: 120, + timer_reason: 'ceremony' + }) + timer_custom_without_text = owner_client.patch("/tournaments/#{tournament.fetch(:id)}/timer", body: { + timestamp_seconds: 120, + timer_reason: 'custom' + }) + timer_invalid_seconds = owner_client.patch("/tournaments/#{tournament.fetch(:id)}/timer", body: { + timestamp_seconds: 'nope' + }) timer_past = owner_client.patch("/tournaments/#{tournament.fetch(:id)}/timer", body: { timestamp_seconds: 0 }) timer_countup_seconds = owner_client.patch("/tournaments/#{tournament.fetch(:id)}/timer", body: { timer_mode: 'countup', @@ -154,6 +251,10 @@ RSpec.describe 'Backend API surface HTTP E2E' do expect(update_response[:status]).to eq(200) expect(update_response.dig(:json, :name)).to start_with('Renamed') + expect(update_response.dig(:json, :location)).to eq('Updated E2E Arena') + expect(update_response.dig(:json, :starts_at)).to eq('2030-07-01T08:00:00Z') + expect(update_response.dig(:json, :ends_at)).to eq('2030-07-01T16:00:00Z') + expect(update_response.dig(:json, :website_url)).to eq('https://example.com/updated-turniere-e2e') expect(auto_recalc_response[:status]).to eq(200) expect(auto_recalc_response.dig(:json, :playoff_teams_amount)).to eq(4) @@ -170,18 +271,41 @@ RSpec.describe 'Backend API surface HTTP E2E' do expect(timer_seconds[:status]).to eq(200) expect(timer_seconds.dig(:json, :timestamp)).to be_a(String) expect(timer_seconds.dig(:json, :timer_mode)).to eq('countdown') + expect(timer_seconds.dig(:json, :timer_reason)).to eq('tournament_start') + expect(timer_seconds.dig(:json, :timer_reason_text)).to be_nil expect(timer_show[:status]).to eq(200) expect(timer_show.dig(:json, :timestamp)).to eq(timer_seconds.dig(:json, :timestamp)) expect(timer_show.dig(:json, :timer_mode)).to eq('countdown') + expect(timer_show.dig(:json, :timer_reason)).to eq('tournament_start') + expect(timer_reason_custom[:status]).to eq(200) + expect(timer_reason_custom.dig(:json, :timestamp)).to eq(timer_seconds.dig(:json, :timestamp)) + expect(timer_reason_custom.dig(:json, :timer_mode)).to eq('countdown') + expect(timer_reason_custom.dig(:json, :timer_reason)).to eq('custom') + expect(timer_reason_custom.dig(:json, :timer_reason_text)).to eq('Pause ran long') + expect(timer_reason_remove[:status]).to eq(200) + expect(timer_reason_remove.dig(:json, :timestamp)).to eq(timer_seconds.dig(:json, :timestamp)) + expect(timer_reason_remove.dig(:json, :timer_mode)).to eq('countdown') + expect(timer_reason_remove.dig(:json, :timer_reason)).to be_nil + expect(timer_reason_remove.dig(:json, :timer_reason_text)).to be_nil expect(timer_countup[:status]).to eq(200) expect(timer_countup.dig(:json, :timestamp)).to be_a(String) expect(timer_countup.dig(:json, :timer_mode)).to eq('countup') + expect(timer_countup.dig(:json, :timer_reason)).to eq('custom') + expect(timer_countup.dig(:json, :timer_reason_text)).to eq('Pause ran long') expect(timer_clear[:status]).to eq(200) expect(timer_clear.dig(:json, :timestamp)).to be_nil expect(timer_clear.dig(:json, :timer_mode)).to be_nil + expect(timer_clear.dig(:json, :timer_reason)).to be_nil + expect(timer_clear.dig(:json, :timer_reason_text)).to be_nil expect(timer_double[:status]).to eq(422) expect(timer_double.dig(:json, :error)).to eq('Only one of timestamp or timestamp_seconds is allowed') + expect(timer_invalid_reason[:status]).to eq(422) + expect(timer_invalid_reason.dig(:json, :error)).to eq('Invalid timer_reason') + expect(timer_custom_without_text[:status]).to eq(422) + expect(timer_custom_without_text.dig(:json, :error)).to eq( + 'timer_reason_text is required for custom timer_reason' + ) expect(timer_invalid_seconds[:status]).to eq(422) expect(timer_invalid_seconds.dig(:json, :error)).to eq('Invalid seconds format') expect(timer_past[:status]).to eq(422) @@ -209,10 +333,8 @@ RSpec.describe 'Backend API surface HTTP E2E' do statistics_before = anonymous_client.get("/tournaments/#{tournament.fetch(:id)}/statistics") matches_all = anonymous_client.get("/tournaments/#{tournament.fetch(:id)}/matches") - matches_not_started = anonymous_client.get("/tournaments/#{tournament.fetch(:id)}/matches", - params: { state: 'not_started' }) - matches_upcoming = anonymous_client.get("/tournaments/#{tournament.fetch(:id)}/matches", - params: { state: 'upcoming' }) + matches_not_started = anonymous_client.get("/tournaments/#{tournament.fetch(:id)}/matches", params: { state: 'not_started' }) + matches_upcoming = anonymous_client.get("/tournaments/#{tournament.fetch(:id)}/matches", params: { state: 'upcoming' }) matches_group_position = anonymous_client.get( "/tournaments/#{tournament.fetch(:id)}/matches", params: { group_stage_position: match.fetch(:position) } @@ -224,8 +346,7 @@ RSpec.describe 'Backend API surface HTTP E2E' do match_show = anonymous_client.get("/matches/#{match.fetch(:id)}") invalid_match_update = owner_client.patch("/matches/#{match.fetch(:id)}", body: { state: 'bogus' }) forbidden_match_update = other_client.patch("/matches/#{match.fetch(:id)}", body: { state: 'in_progress' }) - unauthenticated_match_update = anonymous_client.patch("/matches/#{match.fetch(:id)}", - body: { state: 'in_progress' }) + unauthenticated_match_update = anonymous_client.patch("/matches/#{match.fetch(:id)}", body: { state: 'in_progress' }) stage_not_over = owner_client.patch("/stages/#{group_stage.fetch(:id)}", body: { state: 'finished' }) invalid_stage_update = owner_client.patch("/stages/#{group_stage.fetch(:id)}", body: { state: 'in_progress' }) forbidden_stage_update = other_client.patch("/stages/#{group_stage.fetch(:id)}", body: { state: 'finished' }) @@ -242,12 +363,14 @@ RSpec.describe 'Backend API surface HTTP E2E' do started_match = anonymous_client.get("/matches/#{match.fetch(:id)}") score_to_update = started_match.fetch(:json).fetch(:match_scores).first score_show = anonymous_client.get("/match_scores/#{score_to_update.fetch(:id)}") - score_update = owner_client.patch("/match_scores/#{score_to_update.fetch(:id)}", body: { points: 7 }) + score_update = owner_client.patch("/match_scores/#{score_to_update.fetch(:id)}", body: { points: 7, hidden_points: 11 }) forbidden_score_update = other_client.patch("/match_scores/#{score_to_update.fetch(:id)}", body: { points: 3 }) - unauthenticated_score_update = anonymous_client.patch("/match_scores/#{score_to_update.fetch(:id)}", - body: { points: 3 }) + unauthenticated_score_update = anonymous_client.patch("/match_scores/#{score_to_update.fetch(:id)}", body: { points: 3 }) bet_while_running = owner_client.post("/matches/#{match.fetch(:id)}/bets", body: { team: team.fetch(:id) }) restart_started_match = owner_client.patch("/matches/#{match.fetch(:id)}", body: { state: 'in_progress' }) + stop_started_match = owner_client.patch("/matches/#{match.fetch(:id)}", body: { state: 'not_started' }) + stopped_match = anonymous_client.get("/matches/#{match.fetch(:id)}") + restop_match = owner_client.patch("/matches/#{match.fetch(:id)}", body: { state: 'not_started' }) playoff_stats = anonymous_client.get("/tournaments/#{playoff_only.fetch(:id)}/statistics") tied_playoff_match = playoff_only.fetch(:stages) .flat_map { |stage| stage.fetch(:matches) } @@ -288,9 +411,9 @@ RSpec.describe 'Backend API surface HTTP E2E' do expect(matches_all[:status]).to eq(200) expect(matches_all[:json].count).to eq(tournament_match_count(tournament)) expect(matches_not_started[:status]).to eq(200) - expect(matches_not_started[:json].pluck(:id)).to include(match.fetch(:id)) + expect(matches_not_started[:json].map { |item| item[:id] }).to include(match.fetch(:id)) expect(matches_upcoming[:status]).to eq(200) - expect(matches_upcoming[:json].pluck(:id)).to include(match.fetch(:id)) + expect(matches_upcoming[:json].map { |item| item[:id] }).to include(match.fetch(:id)) expect(matches_group_position[:status]).to eq(200) expect(matches_group_position[:json].map { |item| item.dig(:stage, :level) }.uniq).to eq([-1]) expect(matches_group_position[:json]).to all(include(position: match.fetch(:position))) @@ -322,12 +445,21 @@ RSpec.describe 'Backend API surface HTTP E2E' do expect(score_show[:status]).to eq(200) expect(score_update[:status]).to eq(200) expect(score_update.dig(:json, :points)).to eq(7) + expect(score_update.dig(:json, :hidden_points)).to eq(11) expect(forbidden_score_update[:status]).to eq(403) expect(unauthenticated_score_update[:status]).to eq(401) expect(bet_while_running[:status]).to eq(422) expect(bet_while_running.dig(:json, :error)).to eq('Betting is not allowed while match is in_progress') expect(restart_started_match[:status]).to eq(422) expect(restart_started_match.dig(:json, :error)).to eq("Match can't start in this state") + expect(stop_started_match[:status]).to eq(200) + expect(stop_started_match.dig(:json, :state)).to eq('not_started') + expect(stopped_match[:status]).to eq(200) + expect(stopped_match.dig(:json, :state)).to eq('not_started') + expect(stopped_match.dig(:json, :match_scores).map { |score| score.fetch(:points) }).to all(eq(0)) + expect(stopped_match.dig(:json, :match_scores).map { |score| score.fetch(:hidden_points) }).to all(eq(0)) + expect(restop_match[:status]).to eq(422) + expect(restop_match.dig(:json, :error)).to eq("Match can't stop in this state") expect(playoff_stats[:status]).to eq(501) expect(playoff_stats[:json]).to eq({}) @@ -349,7 +481,6 @@ RSpec.describe 'Backend API surface HTTP E2E' do group = group_stage.fetch(:groups).min_by { |item| item.fetch(:number) } ordered_matches = group.fetch(:matches).sort_by { |item| item.fetch(:position) } first_match = ordered_matches.fetch(0) - second_match = ordered_matches.fetch(1) fourth_match = ordered_matches.fetch(3) upcoming_before = owner_client.get("/tournaments/#{tournament.fetch(:id)}/matches", params: { state: 'upcoming' }) @@ -358,42 +489,87 @@ RSpec.describe 'Backend API surface HTTP E2E' do }) swapped_tournament = fetch_tournament(client: owner_client, tournament_id: tournament.fetch(:id)) swapped_group = swapped_tournament.fetch(:stages) - .find { |stage| stage.fetch(:level) == -1 } - .fetch(:groups) - .find { |item| item.fetch(:id) == group.fetch(:id) } + .find { |stage| stage.fetch(:level) == -1 } + .fetch(:groups) + .find { |item| item.fetch(:id) == group.fetch(:id) } swapped_matches = swapped_group.fetch(:matches).sort_by { |item| item.fetch(:position) } - upcoming_after_swap = owner_client.get("/tournaments/#{tournament.fetch(:id)}/matches", - params: { state: 'upcoming' }) + upcoming_after_swap = owner_client.get("/tournaments/#{tournament.fetch(:id)}/matches", params: { state: 'upcoming' }) finish_group_match_with_winner!(match_id: fourth_match.fetch(:id)) - upcoming_after_finish = owner_client.get("/tournaments/#{tournament.fetch(:id)}/matches", - params: { state: 'upcoming' }) + upcoming_after_finish = owner_client.get("/tournaments/#{tournament.fetch(:id)}/matches", params: { state: 'upcoming' }) expect(upcoming_before[:status]).to eq(200) - expect(upcoming_match_for_group(upcoming_before, - group_id: group.fetch(:id)).fetch(:id)).to eq(first_match.fetch(:id)) + expect(upcoming_match_for_group(upcoming_before, group_id: group.fetch(:id)).fetch(:id)).to eq(first_match.fetch(:id)) expect(swap_response[:status]).to eq(200) - expect(swap_response[:json].pluck(:id)).to match_array([first_match.fetch(:id), fourth_match.fetch(:id)]) + expect(swap_response[:json].map { |match| match[:id] }).to match_array([first_match.fetch(:id), fourth_match.fetch(:id)]) expect(swapped_matches.first.fetch(:id)).to eq(fourth_match.fetch(:id)) expect(swapped_matches.first.fetch(:position)).to eq(first_match.fetch(:position)) - expect(swapped_matches.find do |match| - match.fetch(:id) == first_match.fetch(:id) - end.fetch(:position)).to eq(fourth_match.fetch(:position)) + expect(swapped_matches.find { |match| match.fetch(:id) == first_match.fetch(:id) }.fetch(:position)).to eq(fourth_match.fetch(:position)) expect(upcoming_after_swap[:status]).to eq(200) - expect(upcoming_match_for_group(upcoming_after_swap, - group_id: group.fetch(:id)).fetch(:id)).to eq(fourth_match.fetch(:id)) + expect(upcoming_match_for_group(upcoming_after_swap, group_id: group.fetch(:id)).fetch(:id)).to eq(fourth_match.fetch(:id)) expect(upcoming_after_finish[:status]).to eq(200) - expect(upcoming_match_for_group(upcoming_after_finish, - group_id: group.fetch(:id)).fetch(:id)).to eq(second_match.fetch(:id)) + expect(upcoming_match_for_group(upcoming_after_finish, group_id: group.fetch(:id))).to be_nil + expect(upcoming_after_finish[:json].map { |match| match.fetch(:position) }.uniq) + .to eq([first_match.fetch(:position)]) + end + + it 'starts only a single lagging group before all groups can advance over HTTP' do + tournament = create_group_stage_tournament(client: owner_client, public: false, name_prefix: 'Start Next Groups') + groups = group_stage_groups(tournament) + matches_by_group = indexed_group_matches(groups) + start_group_positions!(matches_by_group:, group: groups[0], positions: [0, 1]) + start_group_positions!(matches_by_group:, group: groups[1], positions: [0]) + start_group_positions!(matches_by_group:, group: groups[2], positions: [0, 1]) + start_group_positions!(matches_by_group:, group: groups[3], positions: [0, 1]) + + first_expected_ids = match_ids_for_positions(matches_by_group:, pairs: [[groups[1], 1]]) + second_expected_ids = match_ids_for_positions( + matches_by_group:, + pairs: groups.map { |group| [group, 2] } + ) + upcoming = owner_client.get("/tournaments/#{tournament.fetch(:id)}/matches", params: { state: 'upcoming' }) + first_start = owner_client.patch("/tournaments/#{tournament.fetch(:id)}/start_next_group_matches") + second_start = owner_client.patch("/tournaments/#{tournament.fetch(:id)}/start_next_group_matches") + + expect_match_ids(upcoming, first_expected_ids) + expect_started_match_ids(first_start, first_expected_ids) + expect_started_match_ids(second_start, second_expected_ids) + end + + it 'starts catch-up waves before the next position is started everywhere over HTTP' do + tournament = create_group_stage_tournament(client: owner_client, public: false, name_prefix: 'Start Catch Up Waves') + groups = group_stage_groups(tournament) + matches_by_group = indexed_group_matches(groups) + start_group_positions!(matches_by_group:, group: groups[0], positions: [0, 1, 2]) + start_group_positions!(matches_by_group:, group: groups[1], positions: [0]) + start_group_positions!(matches_by_group:, group: groups[2], positions: [0, 1, 2]) + start_group_positions!(matches_by_group:, group: groups[3], positions: [0, 1]) + + first_expected_ids = match_ids_for_positions(matches_by_group:, pairs: [[groups[1], 1]]) + second_expected_ids = match_ids_for_positions( + matches_by_group:, + pairs: [[groups[1], 2], [groups[3], 2]] + ) + third_expected_ids = match_ids_for_positions( + matches_by_group:, + pairs: groups.map { |group| [group, 3] } + ) + first_start = owner_client.patch("/tournaments/#{tournament.fetch(:id)}/start_next_group_matches") + second_start = owner_client.patch("/tournaments/#{tournament.fetch(:id)}/start_next_group_matches") + third_start = owner_client.patch("/tournaments/#{tournament.fetch(:id)}/start_next_group_matches") + + expect_started_match_ids(first_start, first_expected_ids) + expect_started_match_ids(second_start, second_expected_ids) + expect_started_match_ids(third_start, third_expected_ids) end def login_client(email:, password:) - client = TurniereE2E::ApiClient.new(base_url:) - response = client.login!(email:, password:) + client = TurniereE2E::ApiClient.new(base_url: base_url) + response = client.login!(email: email, password: password) expect(response[:status]).to eq(200) expect(client.authenticated?).to eq(true) @@ -401,15 +577,15 @@ RSpec.describe 'Backend API surface HTTP E2E' do end def secondary_client - alt_email = ENV.fetch('TURNIERE_E2E_ALT_EMAIL', nil) - alt_password = ENV.fetch('TURNIERE_E2E_ALT_PASSWORD', nil) + alt_email = ENV['TURNIERE_E2E_ALT_EMAIL'] + alt_password = ENV['TURNIERE_E2E_ALT_PASSWORD'] if alt_email && alt_password - client = TurniereE2E::ApiClient.new(base_url:) + client = TurniereE2E::ApiClient.new(base_url: base_url) response = client.login!(email: alt_email, password: alt_password) return client if response[:status] == 200 && client.authenticated? end - client = TurniereE2E::ApiClient.new(base_url:) + client = TurniereE2E::ApiClient.new(base_url: base_url) unique = unique_suffix response = client.post('/users', body: { email: "http-e2e-secondary-#{unique}@example.com", @@ -427,11 +603,15 @@ RSpec.describe 'Backend API surface HTTP E2E' do payload = { name: "#{name_prefix} #{unique_suffix}", description: 'HTTP API surface E2E group-stage tournament', - public:, + public: public, + location: "Venue #{name_prefix}", + starts_at: '2030-06-01T10:00:00+02:00', + ends_at: '2030-06-01T18:00:00+02:00', + website_url: 'https://example.com/turniere-e2e', group_stage: true, playoff_teams_amount: 8, teams: 4.times.flat_map do |group_index| - Array.new(4) do |seed_index| + 4.times.map do |seed_index| { name: "#{name_prefix.tr(' ', '')}-G#{group_index + 1}-S#{seed_index + 1}", group: group_index @@ -442,22 +622,66 @@ RSpec.describe 'Backend API surface HTTP E2E' do response = client.post('/tournaments', body: payload) expect(response[:status]).to eq(201) - fetch_tournament(client:, tournament_id: response.dig(:json, :id)) + fetch_tournament(client: client, tournament_id: response.dig(:json, :id)) end - def create_playoff_tournament(client:, public:, name_prefix:) + def group_stage_groups(tournament) + tournament.fetch(:stages).find { |stage| stage.fetch(:level) == -1 } + .fetch(:groups) + .sort_by { |group| group.fetch(:number) } + end + + def indexed_group_matches(groups) + groups.to_h do |group| + [group.fetch(:id), group.fetch(:matches).sort_by { |match| match.fetch(:position) }] + end + end + + def start_group_positions!(matches_by_group:, group:, positions:) + positions.each do |position| + start_group_match!(match_id: matches_by_group.fetch(group.fetch(:id)).fetch(position).fetch(:id)) + end + end + + def match_ids_for_positions(matches_by_group:, pairs:) + pairs.map do |group, position| + matches_by_group.fetch(group.fetch(:id)).fetch(position).fetch(:id) + end + end + + def expect_match_ids(response, expected_ids) + expect(response[:status]).to eq(200) + expect(response[:json].map { |match| match.fetch(:id) }).to match_array(expected_ids) + end + + def expect_started_match_ids(response, expected_ids) + expect_match_ids(response, expected_ids) + expect(response[:json].map { |match| match.fetch(:state) }.uniq).to eq(['in_progress']) + end + + def player_id_by_name(response, name) + response.dig(:json, :players).find do |player| + player.fetch(:name) == name || player.fetch(:name).start_with?(name.split('@').first) + end.fetch(:id) + end + + def create_playoff_tournament(client:, public:, name_prefix:, teams: nil) payload = { name: "#{name_prefix} #{unique_suffix}", description: 'HTTP API surface E2E playoff tournament', - public:, - teams: Array.new(4) do |index| + public: public, + location: "Venue #{name_prefix}", + starts_at: '2030-06-01T10:00:00+02:00', + ends_at: '2030-06-01T18:00:00+02:00', + website_url: 'https://example.com/turniere-e2e', + teams: teams || 4.times.map do |index| { name: "#{name_prefix.tr(' ', '')}-S#{index + 1}" } end } response = client.post('/tournaments', body: payload) expect(response[:status]).to eq(201) - fetch_tournament(client:, tournament_id: response.dig(:json, :id)) + fetch_tournament(client: client, tournament_id: response.dig(:json, :id)) end def fetch_tournament(client:, tournament_id:) @@ -486,6 +710,11 @@ RSpec.describe 'Backend API surface HTTP E2E' do expect(finish_response[:status]).to eq(200) end + def start_group_match!(match_id:) + start_response = owner_client.patch("/matches/#{match_id}", body: { state: 'in_progress' }) + expect(start_response[:status]).to eq(200) + end + def tournament_match_count(tournament) tournament.fetch(:stages).sum do |stage| stage.fetch(:matches, []).count + stage.fetch(:groups, []).sum { |group| group.fetch(:matches).count } diff --git a/spec/e2e/http/load_test_spec.rb b/spec/e2e/http/load_test_spec.rb new file mode 100644 index 0000000..c7299af --- /dev/null +++ b/spec/e2e/http/load_test_spec.rb @@ -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 diff --git a/spec/e2e/http/metrics_spec.rb b/spec/e2e/http/metrics_spec.rb new file mode 100644 index 0000000..412a1ca --- /dev/null +++ b/spec/e2e/http/metrics_spec.rb @@ -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 diff --git a/spec/e2e/http/tournament_beamers_websocket_spec.rb b/spec/e2e/http/tournament_beamers_websocket_spec.rb index b636741..ffeff40 100644 --- a/spec/e2e/http/tournament_beamers_websocket_spec.rb +++ b/spec/e2e/http/tournament_beamers_websocket_spec.rb @@ -86,7 +86,7 @@ RSpec.describe 'Tournament beamer websocket HTTP E2E' do name: "#{name_prefix} #{SecureRandom.hex(3)}", description: 'Websocket beamer tournament', public: true, - teams: Array.new(4) do |index| + teams: 4.times.map do |index| { name: "#{name_prefix.tr(' ', '')}-S#{index + 1}" } end }) @@ -95,13 +95,13 @@ RSpec.describe 'Tournament beamer websocket HTTP E2E' do end def wait_for_beamer_payload!(cable_client, event_type:, display_state:, timeout: 20) - deadline = Time.zone.now + timeout + deadline = Time.now + timeout loop do - payload = cable_client.wait_for_beamer_payload!(timeout: [deadline - Time.zone.now, 0.1].max) + payload = cable_client.wait_for_beamer_payload!(timeout: [deadline - Time.now, 0.1].max) return payload if payload.fetch('type') == event_type && payload.dig('beamer', 'display_state') == display_state - raise "beamer websocket payload not received within #{timeout}s" if Time.zone.now >= deadline + raise "beamer websocket payload not received within #{timeout}s" if Time.now >= deadline end end end diff --git a/spec/e2e/http/tournament_follow_sync_beamers_websocket_spec.rb b/spec/e2e/http/tournament_follow_sync_beamers_websocket_spec.rb index 49b288d..b550f67 100644 --- a/spec/e2e/http/tournament_follow_sync_beamers_websocket_spec.rb +++ b/spec/e2e/http/tournament_follow_sync_beamers_websocket_spec.rb @@ -85,7 +85,7 @@ RSpec.describe 'Tournament follower sync beamer websocket HTTP E2E' do name: "#{name_prefix} #{SecureRandom.hex(3)}", description: 'Follower beamer websocket source tournament', public: true, - teams: Array.new(4) do |index| + teams: 4.times.map do |index| { name: "#{name_prefix.tr(' ', '')}-S#{index + 1}" } end }) @@ -125,25 +125,25 @@ RSpec.describe 'Tournament follower sync beamer websocket HTTP E2E' do end def wait_for_default_beamer!(tournament_id:, timeout: 20) - deadline = Time.zone.now + timeout + deadline = Time.now + timeout loop do beamer = fetch_beamer(base_url: follower_base_url, tournament_id:, beamer_name: DEFAULT_BEAMER_NAME) return beamer if beamer.fetch(:name) == DEFAULT_BEAMER_NAME - raise "follower default beamer not present within #{timeout}s" if Time.zone.now >= deadline + raise "follower default beamer not present within #{timeout}s" if Time.now >= deadline sleep 0.5 end end def wait_for_beamer_payload!(cable_client, event_type:, display_state:, timeout: 20) - deadline = Time.zone.now + timeout + deadline = Time.now + timeout loop do - payload = cable_client.wait_for_beamer_payload!(timeout: [deadline - Time.zone.now, 0.1].max) + payload = cable_client.wait_for_beamer_payload!(timeout: [deadline - Time.now, 0.1].max) return payload if payload.fetch('type') == event_type && payload.dig('beamer', 'display_state') == display_state - raise "follower beamer websocket payload not received within #{timeout}s" if Time.zone.now >= deadline + raise "follower beamer websocket payload not received within #{timeout}s" if Time.now >= deadline end end end diff --git a/spec/e2e/http/tournament_follow_sync_spec.rb b/spec/e2e/http/tournament_follow_sync_spec.rb index c069cf5..8fac3fc 100644 --- a/spec/e2e/http/tournament_follow_sync_spec.rb +++ b/spec/e2e/http/tournament_follow_sync_spec.rb @@ -23,9 +23,7 @@ RSpec.describe 'Tournament follower sync HTTP E2E' do let(:owner_password) { ENV.fetch('TURNIERE_E2E_PASSWORD') } let(:sync_token) { "sync-#{SecureRandom.hex(8)}" } let(:source_owner_client) { login_client(base_url: source_base_url, email: owner_email, password: owner_password) } - let(:follower_owner_client) do - login_client(base_url: follower_base_url, email: owner_email, password: owner_password) - end + let(:follower_owner_client) { login_client(base_url: follower_base_url, email: owner_email, password: owner_password) } let(:source_anonymous_client) { TurniereE2E::ApiClient.new(base_url: source_base_url) } let(:follower_anonymous_client) { TurniereE2E::ApiClient.new(base_url: follower_base_url) } @@ -41,28 +39,72 @@ RSpec.describe 'Tournament follower sync HTTP E2E' do log_phase!('configure sync and verify initial replication') configure_sync = source_owner_client.patch("/tournaments/#{source.fetch(:id)}", body: { - sync_target_url: "#{follower_base_url}/tournaments/#{follower.fetch(:id)}/sync_state", - sync_auth_token: sync_token - }) + sync_target_url: "#{follower_base_url}/tournaments/#{follower.fetch(:id)}/sync_state", + sync_auth_token: sync_token + }) expect(configure_sync[:status]).to eq(200) - exercise_unrelated_requests!(source_noise:, follower_noise:) + exercise_unrelated_requests!(source_noise: source_noise, follower_noise: follower_noise) source = fetch_tournament(client: source_anonymous_client, tournament_id: source.fetch(:id)) - follower = wait_for_tournament_sync!(source_tournament_id: source.fetch(:id), - follower_tournament_id: follower.fetch(:id)) + follower = wait_for_tournament_sync!(source_tournament_id: source.fetch(:id), follower_tournament_id: follower.fetch(:id)) expect(tournament_signature(follower)).to eq(tournament_signature(source)) - expect(synced_team_id_pairs(source:, follower:)).not_to be_empty + expect(synced_team_id_pairs(source: source, follower: follower)).not_to be_empty + + log_phase!('update match metadata and verify follower mirrors it') + source_group_stage = source.fetch(:stages).find { |stage| stage.fetch(:level) == -1 } + first_source_group = source_group_stage.fetch(:groups).sort_by { |group| group.fetch(:number) }.first + first_group_matches = first_source_group.fetch(:matches).sort_by { |match| match.fetch(:position) } + single_metadata = source_owner_client.patch("/matches/#{first_group_matches.fetch(0).fetch(:id)}", body: { + location: 'Main Hall', + start_time: '17:00', + notes: 'Opening match' + }) + explicit_bulk_metadata = source_owner_client.patch("/tournaments/#{source.fetch(:id)}/matches/metadata", body: { + match_ids: first_group_matches.values_at(1, 2).map { |match| match.fetch(:id) }, + location: 'Court B', + start_time: '17:30' + }) + group_position_metadata = source_owner_client.patch("/tournaments/#{source.fetch(:id)}/matches/metadata", body: { + group_stage_position: 3, + start_time: '18:00', + notes: 'Group position 3 wave' + }) + expect(single_metadata[:status]).to eq(200) + expect(single_metadata.dig(:json, :location)).to eq('Main Hall') + expect(explicit_bulk_metadata[:status]).to eq(200) + expect(explicit_bulk_metadata[:json].map { |match| match.fetch(:location) }).to all(eq('Court B')) + expect(group_position_metadata[:status]).to eq(200) + expect(group_position_metadata[:json].map { |match| match.fetch(:start_time) }).to all(eq('18:00')) + + source = fetch_tournament(client: source_anonymous_client, tournament_id: source.fetch(:id)) + follower = wait_for_tournament_sync!(source_tournament_id: source.fetch(:id), follower_tournament_id: follower.fetch(:id)) + expect(tournament_signature(follower)).to eq(tournament_signature(source)) + + follower_group_stage = follower.fetch(:stages).find { |stage| stage.fetch(:level) == -1 } + follower_group = follower_group_stage.fetch(:groups).sort_by { |group| group.fetch(:number) }.first + follower_first_match = follower_group.fetch(:matches).sort_by { |match| match.fetch(:position) }.first + locked_match_metadata = follower_owner_client.patch("/matches/#{follower_first_match.fetch(:id)}", body: { + location: 'Follower edit' + }) + locked_bulk_metadata = follower_owner_client.patch("/tournaments/#{follower.fetch(:id)}/matches/metadata", body: { + match_ids: [follower_first_match.fetch(:id)], + start_time: '19:00' + }) + expect(locked_match_metadata[:status]).to eq(423) + expect(locked_match_metadata.dig(:json, :error)).to eq('Tournament is in read only follower mode') + expect(locked_bulk_metadata[:status]).to eq(423) + expect(locked_bulk_metadata.dig(:json, :error)).to eq('Tournament is in read only follower mode') log_phase!('swap matches on leader and verify follower behavior') source_group_stage = source.fetch(:stages).find { |stage| stage.fetch(:level) == -1 } - first_source_group = source_group_stage.fetch(:groups).min_by { |group| group.fetch(:number) } + first_source_group = source_group_stage.fetch(:groups).sort_by { |group| group.fetch(:number) }.first second_source_group = source_group_stage.fetch(:groups).sort_by { |group| group.fetch(:number) }[1] first_group_matches = first_source_group.fetch(:matches).sort_by { |match| match.fetch(:position) } first_position_match = first_group_matches.fetch(0) fourth_position_match = first_group_matches.fetch(3) - wrong_group_match = second_source_group.fetch(:matches).min_by { |match| match.fetch(:position) } + wrong_group_match = second_source_group.fetch(:matches).sort_by { |match| match.fetch(:position) }.first follower_group_stage = follower.fetch(:stages).find { |stage| stage.fetch(:level) == -1 } - first_follower_group = follower_group_stage.fetch(:groups).min_by { |group| group.fetch(:number) } + first_follower_group = follower_group_stage.fetch(:groups).sort_by { |group| group.fetch(:number) }.first follower_first_position_match = first_follower_group.fetch(:matches).find do |match| match_team_names(match) == match_team_names(first_position_match) end @@ -75,24 +117,21 @@ RSpec.describe 'Tournament follower sync HTTP E2E' do }) expect(leader_swap[:status]).to eq(200) expect(leader_swap[:json].map { |match| match.fetch(:position) }).to match_array([ - first_position_match.fetch(:position), - fourth_position_match.fetch(:position) - ]) + first_position_match.fetch(:position), + fourth_position_match.fetch(:position) + ]) source = fetch_tournament(client: source_anonymous_client, tournament_id: source.fetch(:id)) - follower = wait_for_tournament_sync!(source_tournament_id: source.fetch(:id), - follower_tournament_id: follower.fetch(:id)) + follower = wait_for_tournament_sync!(source_tournament_id: source.fetch(:id), follower_tournament_id: follower.fetch(:id)) expect(tournament_signature(follower)).to eq(tournament_signature(source)) expect(group_match_positions(source, group_number: first_source_group.fetch(:number))).to eq( group_match_positions(follower, group_number: first_follower_group.fetch(:number)) ) - expect(group_match_positions(source, - group_number: first_source_group.fetch(:number)).fetch(match_team_names(first_position_match))).to eq( - fourth_position_match.fetch(:position) - ) - expect(group_match_positions(source, - group_number: first_source_group.fetch(:number)).fetch(match_team_names(fourth_position_match))).to eq( - first_position_match.fetch(:position) - ) + expect(group_match_positions(source, group_number: first_source_group.fetch(:number)).fetch(match_team_names(first_position_match))).to eq( + fourth_position_match.fetch(:position) + ) + expect(group_match_positions(source, group_number: first_source_group.fetch(:number)).fetch(match_team_names(fourth_position_match))).to eq( + first_position_match.fetch(:position) + ) follower_before_invalid_swap = tournament_signature(follower) source_before_invalid_swap = tournament_signature(source) @@ -103,8 +142,7 @@ RSpec.describe 'Tournament follower sync HTTP E2E' do expect(wrong_group_swap[:status]).to eq(422) expect(wrong_group_swap.dig(:json, :error)).to eq('Matches must be different group matches from the same group') source_after_invalid_swap = fetch_tournament(client: source_anonymous_client, tournament_id: source.fetch(:id)) - follower_after_invalid_swap = fetch_tournament(client: follower_anonymous_client, - tournament_id: follower.fetch(:id)) + follower_after_invalid_swap = fetch_tournament(client: follower_anonymous_client, tournament_id: follower.fetch(:id)) expect(tournament_signature(source_after_invalid_swap)).to eq(source_before_invalid_swap) expect(tournament_signature(follower_after_invalid_swap)).to eq(follower_before_invalid_swap) @@ -118,15 +156,19 @@ RSpec.describe 'Tournament follower sync HTTP E2E' do log_phase!('tighten playoff cutoff and rename teams') update_cutoff = source_owner_client.patch("/tournaments/#{source.fetch(:id)}", body: { - playoff_teams_amount: 4, - instant_finalists_amount: 4, - intermediate_round_participants_amount: 0 - }) + location: 'Source Updated Arena', + starts_at: '2030-07-01T10:00:00+02:00', + ends_at: '2030-07-01T18:00:00+02:00', + website_url: 'https://example.com/updated-sync-source', + playoff_teams_amount: 4, + instant_finalists_amount: 4, + intermediate_round_participants_amount: 0 + }) expect(update_cutoff[:status]).to eq(200) - exercise_unrelated_requests!(source_noise:, follower_noise:) + exercise_unrelated_requests!(source_noise: source_noise, follower_noise: follower_noise) source_group_stage = fetch_tournament(client: source_anonymous_client, tournament_id: source.fetch(:id)) - .fetch(:stages).find { |stage| stage.fetch(:level) == -1 } + .fetch(:stages).find { |stage| stage.fetch(:level) == -1 } rename_source_tournament_teams!( source_tournament_id: source.fetch(:id), team_names: [ @@ -134,35 +176,43 @@ RSpec.describe 'Tournament follower sync HTTP E2E' do group_team_map(source_group_stage.fetch(:groups).last).fetch(2).fetch(:name) ] ) - source_noise.concat(seed_unrelated_tournaments!(client: source_owner_client, prefix: 'Source Midway Noise', - range: 2..3)) - follower_noise.concat(seed_unrelated_tournaments!(client: follower_owner_client, prefix: 'Follower Midway Noise', - range: 3..4)) - exercise_unrelated_requests!(source_noise:, follower_noise:) + source_noise.concat(seed_unrelated_tournaments!(client: source_owner_client, prefix: 'Source Midway Noise', range: 2..3)) + follower_noise.concat(seed_unrelated_tournaments!(client: follower_owner_client, prefix: 'Follower Midway Noise', range: 3..4)) + exercise_unrelated_requests!(source_noise: source_noise, follower_noise: follower_noise) log_phase!('play group stage with deciders') - play_group_with_decider_lifecycle!(source_id: source.fetch(:id), groups: source_group_stage.fetch(:groups).sort_by do |group| - group.fetch(:number) - end) - exercise_unrelated_requests!(source_noise:, follower_noise:) + play_group_with_decider_lifecycle!(source_id: source.fetch(:id), groups: source_group_stage.fetch(:groups).sort_by { |group| group.fetch(:number) }) + exercise_unrelated_requests!(source_noise: source_noise, follower_noise: follower_noise) log_phase!('verify playoff generation replicated') source = fetch_tournament(client: source_anonymous_client, tournament_id: source.fetch(:id)) - follower = wait_for_tournament_sync!(source_tournament_id: source.fetch(:id), - follower_tournament_id: follower.fetch(:id)) + follower = wait_for_tournament_sync!(source_tournament_id: source.fetch(:id), follower_tournament_id: follower.fetch(:id)) expect(tournament_signature(follower)).to eq(tournament_signature(source)) expect(follower.fetch(:stages).map { |stage| stage.fetch(:level) }).to include(-1, 0, 1) - expect(synced_team_id_pairs(source:, follower:)).not_to be_empty + expect(synced_team_id_pairs(source: source, follower: follower)).not_to be_empty expect(source.fetch(:team_action_lists)).to eq([]) expect(follower.fetch(:team_action_lists)).to eq([]) + log_phase!('bulk update playoff match metadata and verify follower mirrors it') + source_playoff_stage = source.fetch(:stages).select { |stage| stage.fetch(:level) >= 0 }.max_by { |stage| stage.fetch(:level) } + playoff_metadata = source_owner_client.patch("/tournaments/#{source.fetch(:id)}/matches/metadata", body: { + stage_level: source_playoff_stage.fetch(:level), + location: 'Finals Arena', + start_time: '21:00', + notes: 'Playoff wave' + }) + expect(playoff_metadata[:status]).to eq(200) + expect(playoff_metadata[:json].map { |match| match.fetch(:location) }).to all(eq('Finals Arena')) + source = fetch_tournament(client: source_anonymous_client, tournament_id: source.fetch(:id)) + follower = wait_for_tournament_sync!(source_tournament_id: source.fetch(:id), follower_tournament_id: follower.fetch(:id)) + expect(tournament_signature(follower)).to eq(tournament_signature(source)) + log_phase!('create team action list on source and verify follower mirrors it') created_list = create_team_action_list!(source.fetch(:id)) expect(created_list.fetch(:key)).to eq(playoff_token_list_key) source = fetch_tournament(client: source_anonymous_client, tournament_id: source.fetch(:id)) - follower = wait_for_tournament_sync!(source_tournament_id: source.fetch(:id), - follower_tournament_id: follower.fetch(:id)) + follower = wait_for_tournament_sync!(source_tournament_id: source.fetch(:id), follower_tournament_id: follower.fetch(:id)) expect(playoff_token_list(source)).not_to be_nil expect(playoff_token_list(follower)).not_to be_nil expect(playoff_token_list(follower).fetch(:team_action_items).map { |item| item.dig(:team, :name) }) @@ -186,80 +236,135 @@ RSpec.describe 'Tournament follower sync HTTP E2E' do log_phase!('mark playoff token collected on source through business key route and verify follower mirrors it') source_token_item = playoff_token_list(source).fetch(:team_action_items).first source_token_update = source_owner_client.patch( - "/tournaments/#{source.fetch(:id)}/team_action_lists/#{playoff_token_list_key}/teams/#{source_token_item.dig( - :team, :id - )}", + "/tournaments/#{source.fetch(:id)}/team_action_lists/#{playoff_token_list_key}/teams/#{source_token_item.dig(:team, :id)}", body: { completed: true } ) expect(source_token_update[:status]).to eq(200) source = fetch_tournament(client: source_anonymous_client, tournament_id: source.fetch(:id)) - follower = wait_for_tournament_sync!(source_tournament_id: source.fetch(:id), - follower_tournament_id: follower.fetch(:id)) + follower = wait_for_tournament_sync!(source_tournament_id: source.fetch(:id), follower_tournament_id: follower.fetch(:id)) expect(playoff_token_item_completed?(source, team_name: source_token_item.dig(:team, :name))).to eq(true) expect(playoff_token_item_completed?(follower, team_name: source_token_item.dig(:team, :name))).to eq(true) follower_token_item = find_team_action_item(follower, team_name: source_token_item.dig(:team, :name)) locked_token_update = follower_owner_client.patch( - "/tournaments/#{follower.fetch(:id)}/team_action_lists/#{playoff_token_list_key}/teams/#{follower_token_item.dig( - :team, :id - )}", + "/tournaments/#{follower.fetch(:id)}/team_action_lists/#{playoff_token_list_key}/teams/#{follower_token_item.dig(:team, :id)}", body: { completed: false } ) expect(locked_token_update[:status]).to eq(423) expect(locked_token_update.dig(:json, :error)).to eq('Tournament is in read only follower mode') - source_noise.concat(seed_unrelated_tournaments!(client: source_owner_client, prefix: 'Source Playoff Noise', - range: 2..4)) - follower_noise.concat(seed_unrelated_tournaments!(client: follower_owner_client, prefix: 'Follower Playoff Noise', - range: 2..5)) - exercise_unrelated_requests!(source_noise:, follower_noise:) + source_noise.concat(seed_unrelated_tournaments!(client: source_owner_client, prefix: 'Source Playoff Noise', range: 2..4)) + follower_noise.concat(seed_unrelated_tournaments!(client: follower_owner_client, prefix: 'Follower Playoff Noise', range: 2..5)) + exercise_unrelated_requests!(source_noise: source_noise, follower_noise: follower_noise) log_phase!('finish playoff bracket') finish_playoff_bracket!(source_id: source.fetch(:id)) - exercise_unrelated_requests!(source_noise:, follower_noise:) + exercise_unrelated_requests!(source_noise: source_noise, follower_noise: follower_noise) log_phase!('verify final follower state and takeover') source = fetch_tournament(client: source_anonymous_client, tournament_id: source.fetch(:id)) - follower = wait_for_tournament_sync!(source_tournament_id: source.fetch(:id), - follower_tournament_id: follower.fetch(:id)) + follower = wait_for_tournament_sync!(source_tournament_id: source.fetch(:id), follower_tournament_id: follower.fetch(:id)) expect(tournament_signature(follower)).to eq(tournament_signature(source)) - expect(synced_team_id_pairs(source:, follower:)).not_to be_empty + expect(synced_team_id_pairs(source: source, follower: follower)).not_to be_empty - disable_follower = follower_owner_client.patch("/tournaments/#{follower.fetch(:id)}", - body: { read_only_mode: false }) + disable_follower = follower_owner_client.patch("/tournaments/#{follower.fetch(:id)}", body: { read_only_mode: false }) expect(disable_follower[:status]).to eq(200) follower_after_disable = fetch_tournament(client: follower_anonymous_client, tournament_id: follower.fetch(:id)) writable_team = follower_after_disable.fetch(:teams).first - takeover_update = follower_owner_client.patch("/teams/#{writable_team.fetch(:id)}", - body: { name: "#{writable_team.fetch(:name)} takeover" }) + takeover_update = follower_owner_client.patch("/teams/#{writable_team.fetch(:id)}", body: { name: "#{writable_team.fetch(:name)} takeover" }) expect(takeover_update[:status]).to eq(200) end + it 'mirrors repeated playoff deletion and recreation into the follower tournament' do + log_phase!('create small source and follower tournaments') + source = create_group_stage_tournament( + client: source_owner_client, + public: false, + name_prefix: 'Sync Playoff Delete Source', + group_count: 2, + teams_per_group: 2, + playoff_teams_amount: 2 + ) + follower = create_follower_tournament(client: follower_owner_client, public: true, name_prefix: 'Sync Playoff Delete Follower') + + configure_sync = source_owner_client.patch("/tournaments/#{source.fetch(:id)}", body: { + sync_target_url: sync_state_url(follower.fetch(:id)), + sync_auth_token: sync_token + }) + expect(configure_sync[:status]).to eq(200) + follower = wait_for_tournament_sync!(source_tournament_id: source.fetch(:id), follower_tournament_id: follower.fetch(:id)) + expect(tournament_signature(follower)).to eq(tournament_signature(source)) + + rounds = playoff_recreation_rounds + rounds.each_with_index do |round, index| + log_phase!("create playoffs round #{index + 1}") + apply_group_score_round!( + source_tournament_id: source.fetch(:id), + scores_by_team_name: round.fetch(:scores_by_team_name) + ) + source = fetch_tournament(client: source_anonymous_client, tournament_id: source.fetch(:id)) + group_stage = source.fetch(:stages).find { |stage| stage.fetch(:level) == -1 } + finish_stage = source_owner_client.patch("/stages/#{group_stage.fetch(:id)}", body: { state: 'finished' }) + expect(finish_stage[:status]).to eq(200) + + source = fetch_tournament(client: source_anonymous_client, tournament_id: source.fetch(:id)) + follower = wait_for_tournament_sync!(source_tournament_id: source.fetch(:id), follower_tournament_id: follower.fetch(:id)) + expect(playoff_matchups(source)).to eq(round.fetch(:expected_playoff_matchups)) + expect(playoff_matchups(follower)).to eq(round.fetch(:expected_playoff_matchups)) + expect(tournament_signature(follower)).to eq(tournament_signature(source)) + + next if index == rounds.size - 1 + + locked_delete = follower_owner_client.delete("/tournaments/#{follower.fetch(:id)}/playoffs") + expect(locked_delete[:status]).to eq(423) + expect(locked_delete.dig(:json, :error)).to eq('Tournament is in read only follower mode') + + log_phase!("delete playoffs round #{index + 1}") + delete_playoffs = source_owner_client.delete("/tournaments/#{source.fetch(:id)}/playoffs") + expect(delete_playoffs[:status]).to eq(200) + + source = fetch_tournament(client: source_anonymous_client, tournament_id: source.fetch(:id)) + follower = wait_for_tournament_sync!(source_tournament_id: source.fetch(:id), follower_tournament_id: follower.fetch(:id)) + expect(source.fetch(:stages).map { |stage| stage.fetch(:level) }).to eq([-1]) + expect(follower.fetch(:stages).map { |stage| stage.fetch(:level) }).to eq([-1]) + expect(source.fetch(:stages).first.fetch(:state)).to eq('in_progress') + expect(follower.fetch(:stages).first.fetch(:state)).to eq('in_progress') + expect(tournament_signature(follower)).to eq(tournament_signature(source)) + end + end + def log_phase!(message) puts "phase #{message}" $stdout.flush end def login_client(base_url:, email:, password:) - client = TurniereE2E::ApiClient.new(base_url:) - response = client.login!(email:, password:) + client = TurniereE2E::ApiClient.new(base_url: base_url) + response = client.login!(email: email, password: password) expect(response[:status]).to eq(200) expect(client.authenticated?).to eq(true) client end - def create_group_stage_tournament(client:, public:, name_prefix:) + def create_group_stage_tournament(client:, public:, name_prefix:, **options) + group_count = options.fetch(:group_count, 4) + teams_per_group = options.fetch(:teams_per_group, 4) + playoff_teams_amount = options.fetch(:playoff_teams_amount, 8) payload = { name: "#{name_prefix} #{unique_suffix}", description: 'Follower sync source tournament', - public:, + public: public, + location: "Source Venue #{name_prefix}", + starts_at: '2030-06-01T10:00:00+02:00', + ends_at: '2030-06-01T18:00:00+02:00', + website_url: 'https://example.com/sync-source', group_stage: true, - playoff_teams_amount: 8, - teams: 4.times.flat_map do |group_index| - Array.new(4) do |seed_index| + playoff_teams_amount: playoff_teams_amount, + teams: group_count.times.flat_map do |group_index| + teams_per_group.times.map do |seed_index| { name: "#{name_prefix.tr(' ', '')}-G#{group_index + 1}-S#{seed_index + 1}", group: group_index @@ -270,34 +375,46 @@ RSpec.describe 'Tournament follower sync HTTP E2E' do response = client.post('/tournaments', body: payload) expect(response[:status]).to eq(201) - fetch_tournament(client:, tournament_id: response.dig(:json, :id)) + fetch_tournament(client: client, tournament_id: response.dig(:json, :id)) + end + + def sync_state_url(follower_id) + "#{follower_base_url}/tournaments/#{follower_id}/sync_state" end def create_playoff_tournament(client:, public:, name_prefix:) payload = { name: "#{name_prefix} #{unique_suffix}", description: 'Unrelated playoff tournament', - public:, - teams: Array.new(4) do |index| + public: public, + location: "Noise Venue #{name_prefix}", + starts_at: '2030-06-01T10:00:00+02:00', + ends_at: '2030-06-01T18:00:00+02:00', + website_url: 'https://example.com/noise', + teams: 4.times.map do |index| { name: "#{name_prefix.tr(' ', '')}-S#{index + 1}" } end } response = client.post('/tournaments', body: payload) expect(response[:status]).to eq(201) - fetch_tournament(client:, tournament_id: response.dig(:json, :id)) + fetch_tournament(client: client, tournament_id: response.dig(:json, :id)) end def create_follower_tournament(client:, public:, name_prefix:) response = client.post('/tournaments', body: { name: "#{name_prefix} #{unique_suffix}", description: 'Follower tournament', - public:, + public: public, + location: "Follower Venue #{name_prefix}", + starts_at: '2030-05-01T10:00:00+02:00', + ends_at: '2030-05-01T18:00:00+02:00', + website_url: 'https://example.com/follower', read_only_mode: true, sync_auth_token: sync_token }) expect(response[:status]).to eq(201) - fetch_tournament(client:, tournament_id: response.dig(:json, :id)) + fetch_tournament(client: client, tournament_id: response.dig(:json, :id)) end def fetch_tournament(client:, tournament_id:) @@ -321,9 +438,9 @@ RSpec.describe 'Tournament follower sync HTTP E2E' do amount = rand(range) Array.new(amount) do |index| if index.even? - create_playoff_tournament(client:, public: false, name_prefix: "#{prefix} Playoff #{index}") + create_playoff_tournament(client: client, public: false, name_prefix: "#{prefix} Playoff #{index}") else - create_group_stage_tournament(client:, public: true, name_prefix: "#{prefix} Groups #{index}") + create_group_stage_tournament(client: client, public: true, name_prefix: "#{prefix} Groups #{index}") end end end @@ -339,7 +456,7 @@ RSpec.describe 'Tournament follower sync HTTP E2E' do update = client.patch("/tournaments/#{tournament.fetch(:id)}", body: { name: updated_name }) expect(update[:status]).to eq(200) - refreshed = fetch_tournament(client:, tournament_id: tournament.fetch(:id)) + refreshed = fetch_tournament(client: client, tournament_id: tournament.fetch(:id)) team = refreshed.fetch(:teams).first return if team.nil? @@ -363,21 +480,21 @@ RSpec.describe 'Tournament follower sync HTTP E2E' do end def wait_for_tournament_sync!(source_tournament_id:, follower_tournament_id:, timeout: 20) - deadline = Time.zone.now + timeout - last_progress_at = Time.zone.now + deadline = Time.now + timeout + last_progress_at = Time.now loop do source = fetch_tournament(client: source_anonymous_client, tournament_id: source_tournament_id) follower = fetch_tournament(client: follower_anonymous_client, tournament_id: follower_tournament_id) return follower if tournament_signature(source) == tournament_signature(follower) - if Time.zone.now - last_progress_at >= 2 + if Time.now - last_progress_at >= 2 puts "sync wait source=#{sync_progress_signature(source).inspect} follower=#{sync_progress_signature(follower).inspect}" $stdout.flush - last_progress_at = Time.zone.now + last_progress_at = Time.now end - if Time.zone.now >= deadline + if Time.now >= deadline raise <<~ERROR Follower did not catch up within #{timeout}s source_signature=#{tournament_signature(source).inspect} @@ -397,8 +514,8 @@ RSpec.describe 'Tournament follower sync HTTP E2E' do end def synced_team_id_pairs(source:, follower:) - source_by_name = source.fetch(:teams).index_by do |team| - team.fetch(:name) + source_by_name = source.fetch(:teams).each_with_object({}) do |team, teams_by_name| + teams_by_name[team.fetch(:name)] = team end follower.fetch(:teams).filter_map do |team| source_team = source_by_name[team.fetch(:name)] @@ -419,18 +536,17 @@ RSpec.describe 'Tournament follower sync HTTP E2E' do end group_stage = fetch_tournament(client: source_anonymous_client, tournament_id: source_id) - .fetch(:stages).find { |stage| stage.fetch(:level) == -1 } + .fetch(:stages).find { |stage| stage.fetch(:level) == -1 } stage_finish = source_owner_client.patch("/stages/#{group_stage.fetch(:id)}", body: { state: 'finished' }) expect(stage_finish[:status]).to eq(422) - expect(stage_finish.dig(:json, - :error)).to eq('Playoffs cannot be generated yet because a decider match is required for an advancing place') + expect(stage_finish.dig(:json, :error)).to eq('Playoffs cannot be generated yet because a decider match is required for an advancing place') expect(stage_finish.dig(:json, :blocking_ties).size).to be >= 2 stage_finish.fetch(:json).fetch(:blocking_ties).each_with_index do |blocking_tie, index| decider_create = source_owner_client.post("/groups/#{blocking_tie.fetch(:group_id)}/matches", body: { - team_ids: blocking_tie.fetch(:team_ids) - }) + team_ids: blocking_tie.fetch(:team_ids) + }) expect(decider_create[:status]).to eq(201) decider_match = decider_create.fetch(:json) @@ -446,8 +562,7 @@ RSpec.describe 'Tournament follower sync HTTP E2E' do expect(finish_decider[:status]).to eq(200) end - finish_stage_after_decider = source_owner_client.patch("/stages/#{group_stage.fetch(:id)}", - body: { state: 'finished' }) + finish_stage_after_decider = source_owner_client.patch("/stages/#{group_stage.fetch(:id)}", body: { state: 'finished' }) expect(finish_stage_after_decider[:status]).to eq(200) end @@ -485,17 +600,105 @@ RSpec.describe 'Tournament follower sync HTTP E2E' do play_match!(match_id: match.fetch(:id), first_score: score_a, second_score: score_b) end + def playoff_recreation_rounds + [ + { + scores_by_team_name: { + 'G1-S1' => 3, + 'G1-S2' => 1, + 'G2-S1' => 3, + 'G2-S2' => 1 + }, + expected_playoff_matchups: [ + %w[G1-S1 G2-S2], + %w[G1-S2 G2-S1] + ] + }, + { + scores_by_team_name: { + 'G1-S1' => 0, + 'G1-S2' => 4, + 'G2-S1' => 0, + 'G2-S2' => 4 + }, + expected_playoff_matchups: [ + %w[G1-S2 G2-S1], + %w[G1-S1 G2-S2] + ] + }, + { + scores_by_team_name: { + 'G1-S1' => 5, + 'G1-S2' => 2, + 'G2-S1' => 2, + 'G2-S2' => 5 + }, + expected_playoff_matchups: [ + %w[G1-S1 G2-S1], + %w[G1-S2 G2-S2] + ] + } + ] + end + + def apply_group_score_round!(source_tournament_id:, scores_by_team_name:) + tournament = fetch_tournament(client: source_anonymous_client, tournament_id: source_tournament_id) + group_stage = tournament.fetch(:stages).find { |stage| stage.fetch(:level) == -1 } + + group_stage.fetch(:groups).each do |group| + group.fetch(:matches).each do |match| + if match.fetch(:state) == 'not_started' + start_response = source_owner_client.patch("/matches/#{match.fetch(:id)}", body: { state: 'in_progress' }) + expect(start_response[:status]).to eq(200) + end + + update_match_points_by_seed!(match.fetch(:id), scores_by_team_name) + + next if match.fetch(:state) == 'finished' + + finish_response = source_owner_client.patch("/matches/#{match.fetch(:id)}", body: { state: 'finished' }) + expect(finish_response[:status]).to eq(200) + end + end + end + + def update_match_points_by_seed!(match_id, scores_by_team_name) + match = source_anonymous_client.get("/matches/#{match_id}") + expect(match[:status]).to eq(200) + + match.fetch(:json).fetch(:match_scores).each do |score| + response = source_owner_client.patch("/match_scores/#{score.fetch(:id)}", body: { + points: scores_by_team_name.fetch(seed_label(score.dig(:team, :name))) + }) + expect(response[:status]).to eq(200) + end + end + + def playoff_matchups(tournament) + entry_stage = tournament.fetch(:stages) + .select { |stage| stage.fetch(:level) >= 0 } + .max_by { |stage| stage.fetch(:level) } + entry_stage.fetch(:matches) + .sort_by { |match| match.fetch(:position) } + .map do |match| + match.fetch(:match_scores) + .map { |score| seed_label(score.dig(:team, :name)) } + .sort + end + end + + def seed_label(team_name) + team_name[/G\d+-S\d+\z/] || team_name + end + def finish_playoff_bracket!(source_id:) loop do tournament = fetch_tournament(client: source_anonymous_client, tournament_id: source_id) playoff_matches = tournament.fetch(:stages) - .select { |stage| stage.fetch(:level) >= 0 } - .flat_map { |stage| stage.fetch(:matches) } - .select do |match| - %w[not_started - in_progress].include?(match.fetch(:state)) && match.fetch(:match_scores).size == 2 - end - .sort_by { |match| [match.dig(:stage, :id) || 0, match.fetch(:position)] } + .select { |stage| stage.fetch(:level) >= 0 } + .flat_map { |stage| stage.fetch(:matches) } + .select { |match| %w[not_started in_progress].include?(match.fetch(:state)) && match.fetch(:match_scores).size == 2 } + .sort_by { |match| [match.dig(:stage, :id) || 0, match.fetch(:position)] } break if playoff_matches.empty? playoff_matches.each_with_index do |match, index| @@ -516,8 +719,7 @@ RSpec.describe 'Tournament follower sync HTTP E2E' do ordered_scores = match.fetch(:json).fetch(:match_scores).sort_by { |score| score.fetch(:id) } [first_score, second_score].each_with_index do |points, index| - response = source_owner_client.patch("/match_scores/#{ordered_scores[index].fetch(:id)}", - body: { points: }) + response = source_owner_client.patch("/match_scores/#{ordered_scores[index].fetch(:id)}", body: { points: points }) expect(response[:status]).to eq(200) end @@ -529,13 +731,18 @@ RSpec.describe 'Tournament follower sync HTTP E2E' do { name: tournament.fetch(:name), public: tournament.fetch(:public), + location: tournament[:location], + starts_at: tournament[:starts_at], + ends_at: tournament[:ends_at], + website_url: tournament[:website_url], playoff_teams_amount: tournament.fetch(:playoff_teams_amount), instant_finalists_amount: tournament.fetch(:instant_finalists_amount), intermediate_round_participants_amount: tournament.fetch(:intermediate_round_participants_amount), timestamp: tournament[:timestamp], - teams: tournament.fetch(:teams).map do |team| - team.slice(:name, :advancing_from_group_stage) - end.sort_by { |team| team.fetch(:name) }, + timer_mode: tournament[:timer_mode], + timer_reason: tournament[:timer_reason], + timer_reason_text: tournament[:timer_reason_text], + teams: tournament.fetch(:teams).map { |team| team.slice(:name, :advancing_from_group_stage) }.sort_by { |team| team.fetch(:name) }, team_action_lists: tournament.fetch(:team_action_lists, []).sort_by { |list| list.fetch(:key) }.map do |list| { key: list.fetch(:key), @@ -576,6 +783,9 @@ RSpec.describe 'Tournament follower sync HTTP E2E' do state: match.fetch(:state), position: match.fetch(:position), decider: match.fetch(:decider), + location: match[:location], + start_time: match[:start_time], + notes: match[:notes], scores: match.fetch(:match_scores).map do |score| { team_name: score.dig(:team, :name), @@ -627,6 +837,6 @@ RSpec.describe 'Tournament follower sync HTTP E2E' do end def playoff_token_item_completed?(tournament, team_name:) - find_team_action_item(tournament, team_name:).fetch(:completed) + find_team_action_item(tournament, team_name: team_name).fetch(:completed) end end diff --git a/spec/e2e/http/tournament_follow_sync_websocket_spec.rb b/spec/e2e/http/tournament_follow_sync_websocket_spec.rb index 339b50e..f748a93 100644 --- a/spec/e2e/http/tournament_follow_sync_websocket_spec.rb +++ b/spec/e2e/http/tournament_follow_sync_websocket_spec.rb @@ -27,8 +27,7 @@ RSpec.describe 'Tournament follower sync websocket HTTP E2E' do let(:follower_anonymous_client) { TurniereE2E::ApiClient.new(base_url: follower_base_url) } it 'broadcasts follower websocket updates after source sync import' do - source = create_group_stage_tournament(client: source_client, base_url: source_base_url, - name_prefix: 'Ws Sync Source') + source = create_group_stage_tournament(client: source_client, base_url: source_base_url, name_prefix: 'Ws Sync Source') follower = create_follower_tournament(name_prefix: 'Ws Sync Follower') configure_sync = source_client.patch("/tournaments/#{source.fetch(:id)}", body: { @@ -62,7 +61,7 @@ RSpec.describe 'Tournament follower sync websocket HTTP E2E' do end def login_client(base_url:) - client = TurniereE2E::ApiClient.new(base_url:) + client = TurniereE2E::ApiClient.new(base_url: base_url) response = client.login!(email: owner_email, password: owner_password) expect(response[:status]).to eq(200) client @@ -76,7 +75,7 @@ RSpec.describe 'Tournament follower sync websocket HTTP E2E' do group_stage: true, playoff_teams_amount: 4, teams: 2.times.flat_map do |group_index| - Array.new(4) do |seed_index| + 4.times.map do |seed_index| { name: "#{name_prefix.tr(' ', '')}-G#{group_index + 1}-S#{seed_index + 1}", group: group_index @@ -85,7 +84,7 @@ RSpec.describe 'Tournament follower sync websocket HTTP E2E' do end }) expect(response[:status]).to eq(201) - fetch_tournament(base_url:, tournament_id: response.dig(:json, :id)) + fetch_tournament(base_url: base_url, tournament_id: response.dig(:json, :id)) end def create_follower_tournament(name_prefix:) @@ -102,7 +101,7 @@ RSpec.describe 'Tournament follower sync websocket HTTP E2E' do end def fetch_tournament(base_url:, tournament_id:) - client = TurniereE2E::ApiClient.new(base_url:) + client = TurniereE2E::ApiClient.new(base_url: base_url) response = client.get("/tournaments/#{tournament_id}") expect(response[:status]).to eq(200) response.fetch(:json) @@ -142,7 +141,7 @@ RSpec.describe 'Tournament follower sync websocket HTTP E2E' do ordered_scores = match_response.fetch(:json).fetch(:match_scores).sort_by { |score| score.fetch(:id) } [first_score, second_score].each_with_index do |points, index| - score_update = source_client.patch("/match_scores/#{ordered_scores[index].fetch(:id)}", body: { points: }) + score_update = source_client.patch("/match_scores/#{ordered_scores[index].fetch(:id)}", body: { points: points }) expect(score_update[:status]).to eq(200) end @@ -151,24 +150,24 @@ RSpec.describe 'Tournament follower sync websocket HTTP E2E' do end def wait_for_list_presence!(tournament_id, timeout: 20) - deadline = Time.zone.now + timeout + deadline = Time.now + timeout loop do - follower = fetch_tournament(base_url: follower_base_url, tournament_id:) + follower = fetch_tournament(base_url: follower_base_url, tournament_id: tournament_id) return follower if follower.fetch(:team_action_lists, []).any? - raise "follower list not present within #{timeout}s" if Time.zone.now >= deadline + raise "follower list not present within #{timeout}s" if Time.now >= deadline sleep 0.5 end end def wait_for_team_action_list_state!(cable_client, team_name:, completed:, timeout: 20) - deadline = Time.zone.now + timeout + deadline = Time.now + timeout loop do - payload = cable_client.wait_for_payload!(timeout: [deadline - Time.zone.now, 0.1].max) - return payload if team_action_item_completed?(payload, team_name:) == completed + payload = cable_client.wait_for_payload!(timeout: [deadline - Time.now, 0.1].max) + return payload if team_action_item_completed?(payload, team_name: team_name) == completed - raise "follower websocket payload not received within #{timeout}s" if Time.zone.now >= deadline + raise "follower websocket payload not received within #{timeout}s" if Time.now >= deadline end end diff --git a/spec/e2e/http/tournament_lifecycle_spec.rb b/spec/e2e/http/tournament_lifecycle_spec.rb index e68079e..ea5ff0a 100644 --- a/spec/e2e/http/tournament_lifecycle_spec.rb +++ b/spec/e2e/http/tournament_lifecycle_spec.rb @@ -14,10 +14,10 @@ RSpec.describe 'Tournament lifecycle HTTP E2E' do let(:base_url) { ENV.fetch('TURNIERE_E2E_BASE_URL', 'http://127.0.0.1:3000') } let(:runner) do TurniereE2E::ScenarioRunner.new( - base_url:, + base_url: base_url, email: ENV.fetch('TURNIERE_E2E_EMAIL'), password: ENV.fetch('TURNIERE_E2E_PASSWORD'), - username: ENV.fetch('TURNIERE_E2E_USERNAME', nil) + username: ENV['TURNIERE_E2E_USERNAME'] ) end @@ -31,7 +31,7 @@ RSpec.describe 'Tournament lifecycle HTTP E2E' do expect(result.dig(:checkpoints, :statistics, :group_scores_count)).to eq(16) playoffs_created = result.dig(:checkpoints, :playoffs_created, :stage_summaries) - playoff_entry_stage = playoffs_created.reject { |stage| stage[:level] == -1 }.max_by { |stage| stage[:level] } + playoff_entry_stage = playoffs_created.select { |stage| stage[:level] != -1 }.max_by { |stage| stage[:level] } expect(playoff_entry_stage[:match_states].count).to eq(4) final_attempt = result.dig(:checkpoints, :final_finish_attempted, :finish_attempt) @@ -49,8 +49,8 @@ RSpec.describe 'Tournament lifecycle HTTP E2E' do expect(created[:intermediate_round_participants_amount]).to eq(2) playoff_entry_stage = result.dig(:checkpoints, :playoffs_created, :stage_summaries) - .reject { |stage| stage[:level] == -1 } - .max_by { |stage| stage[:level] } + .select { |stage| stage[:level] != -1 } + .max_by { |stage| stage[:level] } expect(playoff_entry_stage[:state]).to eq('intermediate_stage') expect(playoff_entry_stage[:match_states].count('single_team')).to eq(3) expect(playoff_entry_stage[:match_states].count('not_started')).to eq(1) @@ -61,6 +61,27 @@ RSpec.describe 'Tournament lifecycle HTTP E2E' do .find { |stage| stage[:level] == 0 }[:match_states]).to eq(%w[finished finished]) end + it 'deletes and recreates group-stage playoffs over HTTP with changed group results' do + result = runner.run_group_stage_playoff_recreation + checkpoints = result.fetch(:checkpoints) + + expect(checkpoints.fetch(:first_playoffs_created).fetch(:playoff_matchups)).to eq([ + %w[G1-S1 G2-S2], + %w[G1-S2 G2-S1] + ]) + expect(checkpoints.fetch(:first_playoffs_deleted).fetch(:stage_summaries).map { |stage| stage.fetch(:level) }).to eq([-1]) + expect(checkpoints.fetch(:first_playoffs_deleted).fetch(:stage_summaries).first.fetch(:state)).to eq('in_progress') + expect(checkpoints.fetch(:second_playoffs_created).fetch(:playoff_matchups)).to eq([ + %w[G1-S2 G2-S1], + %w[G1-S1 G2-S2] + ]) + expect(checkpoints.fetch(:second_playoffs_deleted).fetch(:stage_summaries).map { |stage| stage.fetch(:level) }).to eq([-1]) + expect(checkpoints.fetch(:third_playoffs_created).fetch(:playoff_matchups)).to eq([ + %w[G1-S1 G2-S1], + %w[G1-S2 G2-S2] + ]) + end + { won: [1, 2], lost: [2, 1], @@ -76,8 +97,7 @@ RSpec.describe 'Tournament lifecycle HTTP E2E' do expect(positions.fetch('G1-S2')).to eq(expected_positions[1]) if direct_result == :drawn - expect(ordered_scores[0].slice(:team_name, :position, :group_points, :difference_in_points, :scored_points, - :received_points)) + expect(ordered_scores[0].slice(:team_name, :position, :group_points, :difference_in_points, :scored_points, :received_points)) .to eq( team_name: 'G1-S1', position: 1, @@ -86,8 +106,7 @@ RSpec.describe 'Tournament lifecycle HTTP E2E' do scored_points: 4, received_points: 1 ) - expect(ordered_scores[1].slice(:team_name, :position, :group_points, :difference_in_points, :scored_points, - :received_points)) + expect(ordered_scores[1].slice(:team_name, :position, :group_points, :difference_in_points, :scored_points, :received_points)) .to eq( team_name: 'G1-S2', position: 1, @@ -98,10 +117,9 @@ RSpec.describe 'Tournament lifecycle HTTP E2E' do ) else expected_first_group_points = direct_result == :lost ? 9 : 6 - expected_second_group_points = 6 + expected_second_group_points = direct_result == :lost ? 6 : 6 - expect(ordered_scores[0].slice(:team_name, :position, :group_points, :difference_in_points, :scored_points, - :received_points)) + expect(ordered_scores[0].slice(:team_name, :position, :group_points, :difference_in_points, :scored_points, :received_points)) .to eq( team_name: expected_positions[0] == 1 ? 'G1-S1' : 'G1-S2', position: 1, @@ -110,8 +128,7 @@ RSpec.describe 'Tournament lifecycle HTTP E2E' do scored_points: 5, received_points: 2 ) - expect(ordered_scores[1].slice(:team_name, :position, :group_points, :difference_in_points, :scored_points, - :received_points)) + expect(ordered_scores[1].slice(:team_name, :position, :group_points, :difference_in_points, :scored_points, :received_points)) .to eq( team_name: expected_positions[0] == 1 ? 'G1-S2' : 'G1-S1', position: 2, @@ -126,11 +143,28 @@ RSpec.describe 'Tournament lifecycle HTTP E2E' do [2, 3, 5, 8].each do |team_count| it "drives a #{team_count}-team playoff-only tournament over HTTP to completion" do - result = runner.run_playoff_only(team_count:) + result = runner.run_playoff_only(team_count: team_count) completed_stages = result.dig(:checkpoints, :completed, :stage_summaries) final_stage = completed_stages.find { |stage| stage[:level] == 0 } expect(final_stage[:match_states]).to eq(team_count > 3 ? %w[finished finished] : ['finished']) end end + + it 'records ordered owner-readable transaction history over HTTP' do + result = runner.run_transaction_log_history + log = result.dig(:checkpoints, :transaction_log) + + expect(log.fetch(:sequences)).to eq((1..log.fetch(:sequences).count).to_a) + expect(log.fetch(:actions)).to include( + 'tournament.created', + 'match.state_changed', + 'match_score.updated', + 'playoffs.start_failed', + 'decider_match.created', + 'playoffs.started' + ) + expect(log.fetch(:messages).join("\n")).to include('failed because decider missing') + expect(log.fetch(:messages).join("\n")).to include('started playoffs, advancing teams:') + end end diff --git a/spec/e2e/http/tournament_rendering_spec.rb b/spec/e2e/http/tournament_rendering_spec.rb index 074bb53..cf89137 100644 --- a/spec/e2e/http/tournament_rendering_spec.rb +++ b/spec/e2e/http/tournament_rendering_spec.rb @@ -15,20 +15,17 @@ RSpec.describe 'Tournament rendering HTTP E2E' do let(:expect_profiling) { ENV.fetch('TURNIERE_E2E_EXPECT_PROFILING', 'true') == 'true' } let(:runner) do TurniereE2E::ScenarioRunner.new( - base_url:, + base_url: base_url, email: ENV.fetch('TURNIERE_E2E_EMAIL'), password: ENV.fetch('TURNIERE_E2E_PASSWORD'), - username: ENV.fetch('TURNIERE_E2E_USERNAME', nil) + username: ENV['TURNIERE_E2E_USERNAME'] ) end [ - { group_count: 8, teams_per_group: 4, playoff_teams_amount: 8, max_request_duration_ms: 1000, - max_server_duration_ms: 1000 }, - { group_count: 64, teams_per_group: 6, playoff_teams_amount: 64, max_request_duration_ms: 5000, - max_server_duration_ms: 5000 }, - { group_count: 4, teams_per_group: 32, playoff_teams_amount: 4, max_request_duration_ms: 5000, - max_server_duration_ms: 5000 } + { group_count: 8, teams_per_group: 4, playoff_teams_amount: 8, max_request_duration_ms: 1000, max_server_duration_ms: 1000 }, + { group_count: 64, teams_per_group: 6, playoff_teams_amount: 64, max_request_duration_ms: 5000, max_server_duration_ms: 5000 }, + { group_count: 4, teams_per_group: 32, playoff_teams_amount: 4, max_request_duration_ms: 5000, max_server_duration_ms: 5000 } ].each do |scenario| it "creates #{scenario[:group_count]}x#{scenario[:teams_per_group]} via API and renders within budget" do result = runner.run_group_stage_render_profile( diff --git a/spec/e2e/http/tournament_sync_test_spec.rb b/spec/e2e/http/tournament_sync_test_spec.rb index f63bfc2..a3f698b 100644 --- a/spec/e2e/http/tournament_sync_test_spec.rb +++ b/spec/e2e/http/tournament_sync_test_spec.rb @@ -21,7 +21,7 @@ RSpec.describe 'Tournament sync test HTTP E2E' do let(:owner_password) { ENV.fetch('TURNIERE_E2E_PASSWORD') } let(:sync_token) { "sync-test-#{SecureRandom.hex(6)}" } let(:client) { login_client } - let(:anonymous_client) { TurniereE2E::ApiClient.new(base_url:) } + let(:anonymous_client) { TurniereE2E::ApiClient.new(base_url: base_url) } it 'roundtrips current snapshot into follower tournament through test_sync endpoint' do follower = create_follower_tournament(name_prefix: 'Sync Test Follower') @@ -33,7 +33,11 @@ RSpec.describe 'Tournament sync test HTTP E2E' do rename = client.patch("/tournaments/#{leader.fetch(:id)}", body: { name: "Leader Renamed #{SecureRandom.hex(3)}", - description: 'Roundtrip sync test' + description: 'Roundtrip sync test', + location: 'Sync Test Arena', + starts_at: '2030-08-01T10:00:00+02:00', + ends_at: '2030-08-01T18:00:00+02:00', + website_url: 'https://example.com/sync-test' }) expect(rename[:status]).to eq(200) @@ -44,16 +48,57 @@ RSpec.describe 'Tournament sync test HTTP E2E' do expect(test_sync[:status]).to eq(200) expect(test_sync.dig(:json, :sync_last_pushed_at)).not_to be_nil expect(test_sync.dig(:json, :sync_last_push_error)).to be_nil + expect(test_sync.dig(:json, :sync_auth_configured)).to eq(true) + expect_no_sync_token_exposed!(test_sync, sync_token) after_leader = fetch_tournament(leader.fetch(:id)) after_follower = fetch_tournament(follower.fetch(:id)) expect(after_follower.fetch(:name)).to eq(after_leader.fetch(:name)) expect(after_follower.fetch(:description)).to eq(after_leader.fetch(:description)) + expect(after_follower.fetch(:location)).to eq(after_leader.fetch(:location)) + expect(after_follower.fetch(:starts_at)).to eq(after_leader.fetch(:starts_at)) + expect(after_follower.fetch(:ends_at)).to eq(after_leader.fetch(:ends_at)) + expect(after_follower.fetch(:website_url)).to eq(after_leader.fetch(:website_url)) expect(after_follower.fetch(:teams).map { |team| team.fetch(:name) }) .to match_array(after_leader.fetch(:teams).map { |team| team.fetch(:name) }) end + it 'never exposes sync auth token after create, update, show, or sync test' do + follower_create = client.post('/tournaments', body: { + name: "Protected Follower #{SecureRandom.hex(3)}", + description: 'Follower token should stay write only', + public: true, + read_only_mode: true, + sync_auth_token: sync_token + }) + expect(follower_create[:status]).to eq(201) + expect_no_sync_token_exposed!(follower_create, sync_token) + + follower_id = follower_create.dig(:json, :id) + follower_owner_read = client.get("/tournaments/#{follower_id}") + expect(follower_owner_read[:status]).to eq(200) + expect(follower_owner_read.dig(:json, :sync_auth_configured)).to eq(true) + expect_no_sync_token_exposed!(follower_owner_read, sync_token) + + follower_public_read = anonymous_client.get("/tournaments/#{follower_id}") + expect(follower_public_read[:status]).to eq(200) + expect_no_sync_token_exposed!(follower_public_read, sync_token) + + leader = create_group_stage_tournament(name_prefix: 'Protected Leader') + configure_sync = client.patch("/tournaments/#{leader.fetch(:id)}", body: { + sync_target_url: "#{base_url}/tournaments/#{follower_id}/sync_state", + sync_auth_token: sync_token + }) + expect(configure_sync[:status]).to eq(200) + expect(configure_sync.dig(:json, :sync_auth_configured)).to eq(true) + expect_no_sync_token_exposed!(configure_sync, sync_token) + + test_sync = client.post("/tournaments/#{leader.fetch(:id)}/test_sync") + expect(test_sync[:status]).to eq(200) + expect_no_sync_token_exposed!(test_sync, sync_token) + end + it 'syncs team action list changes made through item id route and business key route' do follower = create_follower_tournament(name_prefix: 'Sync Action Follower') leader = create_group_stage_tournament( @@ -74,8 +119,7 @@ RSpec.describe 'Tournament sync test HTTP E2E' do expect(update_by_item_id[:status]).to eq(200) update_by_business_key = client.patch( - "/tournaments/#{leader.fetch(:id)}/team_action_lists/#{playoff_token_list_key}/teams/#{second_item.dig(:team, - :id)}", + "/tournaments/#{leader.fetch(:id)}/team_action_lists/#{playoff_token_list_key}/teams/#{second_item.dig(:team, :id)}", body: { completed: true } ) expect(update_by_business_key[:status]).to eq(200) @@ -104,22 +148,25 @@ RSpec.describe 'Tournament sync test HTTP E2E' do missing_item_update = client.patch('/team_action_items/999999999', body: { completed: true }) expect(missing_item_update[:status]).to eq(404) + expect(missing_item_update.dig(:json, :error)).to include("Couldn't find TeamActionItem") missing_list_update = client.patch( "/tournaments/#{leader.fetch(:id)}/team_action_lists/unknown_key/teams/#{first_item.dig(:team, :id)}", body: { completed: true } ) expect(missing_list_update[:status]).to eq(404) + expect(missing_list_update.dig(:json, :error)).to include("Couldn't find TeamActionList") missing_team_update = client.patch( "/tournaments/#{leader.fetch(:id)}/team_action_lists/#{playoff_token_list_key}/teams/999999999", body: { completed: true } ) expect(missing_team_update[:status]).to eq(404) + expect(missing_team_update.dig(:json, :error)).to include("Couldn't find TeamActionItem") end def login_client - api_client = TurniereE2E::ApiClient.new(base_url:) + api_client = TurniereE2E::ApiClient.new(base_url: base_url) response = api_client.login!(email: owner_email, password: owner_password) expect(response[:status]).to eq(200) @@ -134,7 +181,7 @@ RSpec.describe 'Tournament sync test HTTP E2E' do group_stage: true, playoff_teams_amount: 4, teams: 2.times.flat_map do |group_index| - Array.new(4) do |seed_index| + 4.times.map do |seed_index| { name: "#{name_prefix.tr(' ', '')}-G#{group_index + 1}-S#{seed_index + 1}", group: group_index @@ -142,10 +189,10 @@ RSpec.describe 'Tournament sync test HTTP E2E' do end end } - body[:sync_target_url] = sync_target_url if sync_target_url.present? - body[:sync_auth_token] = sync_auth_token if sync_auth_token.present? + body[:sync_target_url] = sync_target_url unless sync_target_url.nil? || sync_target_url.empty? + body[:sync_auth_token] = sync_auth_token unless sync_auth_token.nil? || sync_auth_token.empty? - response = client.post('/tournaments', body:) + response = client.post('/tournaments', body: body) expect(response[:status]).to eq(201) fetch_tournament(response.dig(:json, :id)) end @@ -168,6 +215,14 @@ RSpec.describe 'Tournament sync test HTTP E2E' do response.fetch(:json) end + def expect_no_sync_token_exposed!(response, token) + serialized_json = response.fetch(:json).inspect + + expect(serialized_json).not_to include('sync_auth_token') + expect(serialized_json).not_to include('sync_auth_token_digest') + expect(serialized_json).not_to include(token) + end + def finish_group_stage_and_create_playoffs!(tournament_id) tournament = fetch_tournament(tournament_id) group_stage = tournament.fetch(:stages).find { |stage| stage.fetch(:level) == -1 } @@ -202,7 +257,7 @@ RSpec.describe 'Tournament sync test HTTP E2E' do ordered_scores = match_response.fetch(:json).fetch(:match_scores).sort_by { |score| score.fetch(:id) } [first_score, second_score].each_with_index do |points, index| - score_update = client.patch("/match_scores/#{ordered_scores[index].fetch(:id)}", body: { points: }) + score_update = client.patch("/match_scores/#{ordered_scores[index].fetch(:id)}", body: { points: points }) expect(score_update[:status]).to eq(200) end diff --git a/spec/e2e/http/tournament_team_action_lists_websocket_spec.rb b/spec/e2e/http/tournament_team_action_lists_websocket_spec.rb index 4f3506c..d61149e 100644 --- a/spec/e2e/http/tournament_team_action_lists_websocket_spec.rb +++ b/spec/e2e/http/tournament_team_action_lists_websocket_spec.rb @@ -21,7 +21,7 @@ RSpec.describe 'Tournament team action lists websocket HTTP E2E' do let(:owner_email) { ENV.fetch('TURNIERE_E2E_EMAIL') } let(:owner_password) { ENV.fetch('TURNIERE_E2E_PASSWORD') } let(:client) { login_client } - let(:anonymous_client) { TurniereE2E::ApiClient.new(base_url:) } + let(:anonymous_client) { TurniereE2E::ApiClient.new(base_url: base_url) } it 'broadcasts initial and updated team action list state over websocket' do tournament = create_group_stage_tournament(name_prefix: 'Ws Leader') @@ -30,7 +30,7 @@ RSpec.describe 'Tournament team action lists websocket HTTP E2E' do tournament = fetch_tournament(tournament.fetch(:id)) token_list = find_playoff_token_list!(tournament) - cable_client = TurniereE2E::ActionCableClient.new(base_url:) + cable_client = TurniereE2E::ActionCableClient.new(base_url: base_url) cable_client.subscribe_team_action_lists!(tournament_id: tournament.fetch(:id)) initial_payload = cable_client.wait_for_payload! @@ -50,8 +50,7 @@ RSpec.describe 'Tournament team action lists websocket HTTP E2E' do expect(team_action_item_completed?(first_update, team_name: first_item.dig(:team, :name))).to eq(true) update_by_business_key = client.patch( - "/tournaments/#{tournament.fetch(:id)}/team_action_lists/#{playoff_token_list_key}/teams/#{second_item.dig(:team, - :id)}", + "/tournaments/#{tournament.fetch(:id)}/team_action_lists/#{playoff_token_list_key}/teams/#{second_item.dig(:team, :id)}", body: { completed: true } ) expect(update_by_business_key[:status]).to eq(200) @@ -68,7 +67,7 @@ RSpec.describe 'Tournament team action lists websocket HTTP E2E' do end def login_client - api_client = TurniereE2E::ApiClient.new(base_url:) + api_client = TurniereE2E::ApiClient.new(base_url: base_url) response = api_client.login!(email: owner_email, password: owner_password) expect(response[:status]).to eq(200) @@ -83,7 +82,7 @@ RSpec.describe 'Tournament team action lists websocket HTTP E2E' do group_stage: true, playoff_teams_amount: 4, teams: 2.times.flat_map do |group_index| - Array.new(4) do |seed_index| + 4.times.map do |seed_index| { name: "#{name_prefix.tr(' ', '')}-G#{group_index + 1}-S#{seed_index + 1}", group: group_index @@ -135,7 +134,7 @@ RSpec.describe 'Tournament team action lists websocket HTTP E2E' do ordered_scores = match_response.fetch(:json).fetch(:match_scores).sort_by { |score| score.fetch(:id) } [first_score, second_score].each_with_index do |points, index| - score_update = client.patch("/match_scores/#{ordered_scores[index].fetch(:id)}", body: { points: }) + score_update = client.patch("/match_scores/#{ordered_scores[index].fetch(:id)}", body: { points: points }) expect(score_update[:status]).to eq(200) end @@ -144,12 +143,12 @@ RSpec.describe 'Tournament team action lists websocket HTTP E2E' do end def wait_for_team_action_list_state!(cable_client, team_name:, completed:, timeout: 20) - deadline = Time.zone.now + timeout + deadline = Time.now + timeout loop do - payload = cable_client.wait_for_payload!(timeout: [deadline - Time.zone.now, 0.1].max) - return payload if team_action_item_completed?(payload, team_name:) == completed + payload = cable_client.wait_for_payload!(timeout: [deadline - Time.now, 0.1].max) + return payload if team_action_item_completed?(payload, team_name: team_name) == completed - raise "team action websocket payload not received within #{timeout}s" if Time.zone.now >= deadline + raise "team action websocket payload not received within #{timeout}s" if Time.now >= deadline end end diff --git a/spec/e2e/http/tournament_timer_follow_sync_websocket_spec.rb b/spec/e2e/http/tournament_timer_follow_sync_websocket_spec.rb index e933315..5b101d6 100644 --- a/spec/e2e/http/tournament_timer_follow_sync_websocket_spec.rb +++ b/spec/e2e/http/tournament_timer_follow_sync_websocket_spec.rb @@ -40,30 +40,77 @@ RSpec.describe 'Tournament timer follower sync websocket HTTP E2E' do initial_payload = cable_client.wait_for_timer_payload! expect(initial_payload.fetch('timestamp')).to be_nil expect(initial_payload.fetch('timer_mode')).to be_nil + expect(initial_payload.fetch('timer_reason')).to be_nil + expect(initial_payload.fetch('timer_reason_text')).to be_nil - countdown = source_client.patch("/tournaments/#{source.fetch(:id)}/timer", body: { timestamp_seconds: 120 }) + countdown = source_client.patch("/tournaments/#{source.fetch(:id)}/timer", body: { + timestamp_seconds: 120, + timer_reason: 'match_end' + }) expect(countdown[:status]).to eq(200) + sync_now_if_requested!(source.fetch(:id)) countdown_payload = wait_for_timer_state!(cable_client, timer_mode: 'countdown') expect(countdown_payload.fetch('timestamp')).to eq(countdown.dig(:json, :timestamp)) + expect(countdown_payload.fetch('timer_reason')).to eq('match_end') + expect(countdown_payload.fetch('timer_reason_text')).to be_nil - countup = source_client.patch("/tournaments/#{source.fetch(:id)}/timer", body: { timer_mode: 'countup' }) + reason_update = source_client.patch("/tournaments/#{source.fetch(:id)}/timer", body: { + timer_reason: 'custom', + timer_reason_text: 'Follower reason after start' + }) + expect(reason_update[:status]).to eq(200) + sync_now_if_requested!(source.fetch(:id)) + + reason_payload = wait_for_timer_payload_matching!(cable_client) do |payload| + payload.fetch('timestamp') == countdown.dig(:json, :timestamp) && + payload.fetch('timer_mode') == 'countdown' && + payload.fetch('timer_reason') == 'custom' && + payload.fetch('timer_reason_text') == 'Follower reason after start' + end + expect(reason_payload.fetch('timestamp')).to eq(countdown.dig(:json, :timestamp)) + + reason_clear = source_client.patch("/tournaments/#{source.fetch(:id)}/timer", body: { + timer_reason: nil + }) + expect(reason_clear[:status]).to eq(200) + sync_now_if_requested!(source.fetch(:id)) + + reason_cleared_payload = wait_for_timer_payload_matching!(cable_client) do |payload| + payload.fetch('timestamp') == countdown.dig(:json, :timestamp) && + payload.fetch('timer_mode') == 'countdown' && + payload.fetch('timer_reason').nil? && + payload.fetch('timer_reason_text').nil? + end + expect(reason_cleared_payload.fetch('timestamp')).to eq(countdown.dig(:json, :timestamp)) + + countup = source_client.patch("/tournaments/#{source.fetch(:id)}/timer", body: { + timer_mode: 'countup', + timer_reason: 'custom', + timer_reason_text: 'Follower sync custom reason' + }) expect(countup[:status]).to eq(200) + sync_now_if_requested!(source.fetch(:id)) countup_payload = wait_for_timer_state!(cable_client, timer_mode: 'countup') expect(countup_payload.fetch('timestamp')).to eq(countup.dig(:json, :timestamp)) + expect(countup_payload.fetch('timer_reason')).to eq('custom') + expect(countup_payload.fetch('timer_reason_text')).to eq('Follower sync custom reason') clear = source_client.patch("/tournaments/#{source.fetch(:id)}/timer", body: { clear_timer: true }) expect(clear[:status]).to eq(200) + sync_now_if_requested!(source.fetch(:id)) cleared_payload = wait_for_timer_state!(cable_client, timer_mode: nil) expect(cleared_payload.fetch('timestamp')).to be_nil + expect(cleared_payload.fetch('timer_reason')).to be_nil + expect(cleared_payload.fetch('timer_reason_text')).to be_nil ensure cable_client&.close end def login_client(base_url:) - client = TurniereE2E::ApiClient.new(base_url:) + client = TurniereE2E::ApiClient.new(base_url: base_url) response = client.login!(email: owner_email, password: owner_password) expect(response[:status]).to eq(200) client @@ -74,7 +121,7 @@ RSpec.describe 'Tournament timer follower sync websocket HTTP E2E' do name: "#{name_prefix} #{SecureRandom.hex(3)}", description: 'Follower websocket timer source tournament', public: false, - teams: Array.new(4) do |index| + teams: 4.times.map do |index| { name: "#{name_prefix.tr(' ', '')}-#{index + 1}" } end }) @@ -95,13 +142,26 @@ RSpec.describe 'Tournament timer follower sync websocket HTTP E2E' do response.fetch(:json) end - def wait_for_timer_state!(cable_client, timer_mode:, timeout: 20) - deadline = Time.zone.now + timeout - loop do - payload = cable_client.wait_for_timer_payload!(timeout: [deadline - Time.zone.now, 0.1].max) - return payload if payload.fetch('timer_mode') == timer_mode + def sync_now_if_requested!(source_tournament_id) + return unless ENV.fetch('TURNIERE_E2E_SYNC_MODE', nil) == 'manual' - raise "follower timer websocket payload not received within #{timeout}s" if Time.zone.now >= deadline + response = source_client.post("/tournaments/#{source_tournament_id}/test_sync") + expect(response[:status]).to eq(200) + end + + def wait_for_timer_state!(cable_client, timer_mode:, timeout: 20) + wait_for_timer_payload_matching!(cable_client, timeout: timeout) do |payload| + payload.fetch('timer_mode') == timer_mode + end + end + + def wait_for_timer_payload_matching!(cable_client, timeout: 20) + deadline = Time.now + timeout + loop do + payload = cable_client.wait_for_timer_payload!(timeout: [deadline - Time.now, 0.1].max) + return payload if yield(payload) + + raise "follower timer websocket payload not received within #{timeout}s" if Time.now >= deadline end end end diff --git a/spec/e2e/http/tournament_timer_websocket_spec.rb b/spec/e2e/http/tournament_timer_websocket_spec.rb index 435dad3..69fead4 100644 --- a/spec/e2e/http/tournament_timer_websocket_spec.rb +++ b/spec/e2e/http/tournament_timer_websocket_spec.rb @@ -25,36 +25,78 @@ RSpec.describe 'Tournament timer websocket HTTP E2E' do it 'broadcasts timer state changes over websocket' do tournament = create_playoff_tournament(name_prefix: 'Ws Timer') - cable_client = TurniereE2E::ActionCableClient.new(base_url:) + cable_client = TurniereE2E::ActionCableClient.new(base_url: base_url) cable_client.subscribe_timer!(tournament_id: tournament.fetch(:id)) initial_payload = cable_client.wait_for_timer_payload! expect(initial_payload.fetch('timestamp')).to be_nil expect(initial_payload.fetch('timer_mode')).to be_nil + expect(initial_payload.fetch('timer_reason')).to be_nil + expect(initial_payload.fetch('timer_reason_text')).to be_nil - countdown = client.patch("/tournaments/#{tournament.fetch(:id)}/timer", body: { timestamp_seconds: 120 }) + countdown = client.patch("/tournaments/#{tournament.fetch(:id)}/timer", body: { + timestamp_seconds: 120, + timer_reason: 'playoff_start' + }) expect(countdown[:status]).to eq(200) countdown_payload = wait_for_timer_state!(cable_client, timer_mode: 'countdown') expect(countdown_payload.fetch('timestamp')).to eq(countdown.dig(:json, :timestamp)) + expect(countdown_payload.fetch('timer_reason')).to eq('playoff_start') + expect(countdown_payload.fetch('timer_reason_text')).to be_nil - countup = client.patch("/tournaments/#{tournament.fetch(:id)}/timer", body: { timer_mode: 'countup' }) + reason_update = client.patch("/tournaments/#{tournament.fetch(:id)}/timer", body: { + timer_reason: 'custom', + timer_reason_text: 'Reason changed after start' + }) + expect(reason_update[:status]).to eq(200) + + reason_payload = wait_for_timer_payload_matching!(cable_client) do |payload| + payload.fetch('timestamp') == countdown.dig(:json, :timestamp) && + payload.fetch('timer_mode') == 'countdown' && + payload.fetch('timer_reason') == 'custom' && + payload.fetch('timer_reason_text') == 'Reason changed after start' + end + expect(reason_payload.fetch('timestamp')).to eq(countdown.dig(:json, :timestamp)) + + reason_clear = client.patch("/tournaments/#{tournament.fetch(:id)}/timer", body: { + timer_reason: nil + }) + expect(reason_clear[:status]).to eq(200) + + reason_cleared_payload = wait_for_timer_payload_matching!(cable_client) do |payload| + payload.fetch('timestamp') == countdown.dig(:json, :timestamp) && + payload.fetch('timer_mode') == 'countdown' && + payload.fetch('timer_reason').nil? && + payload.fetch('timer_reason_text').nil? + end + expect(reason_cleared_payload.fetch('timestamp')).to eq(countdown.dig(:json, :timestamp)) + + countup = client.patch("/tournaments/#{tournament.fetch(:id)}/timer", body: { + timer_mode: 'countup', + timer_reason: 'custom', + timer_reason_text: 'Manual overtime' + }) expect(countup[:status]).to eq(200) countup_payload = wait_for_timer_state!(cable_client, timer_mode: 'countup') expect(countup_payload.fetch('timestamp')).to eq(countup.dig(:json, :timestamp)) + expect(countup_payload.fetch('timer_reason')).to eq('custom') + expect(countup_payload.fetch('timer_reason_text')).to eq('Manual overtime') clear = client.patch("/tournaments/#{tournament.fetch(:id)}/timer", body: { clear_timer: true }) expect(clear[:status]).to eq(200) cleared_payload = wait_for_timer_state!(cable_client, timer_mode: nil) expect(cleared_payload.fetch('timestamp')).to be_nil + expect(cleared_payload.fetch('timer_reason')).to be_nil + expect(cleared_payload.fetch('timer_reason_text')).to be_nil ensure cable_client&.close end def login_client - api_client = TurniereE2E::ApiClient.new(base_url:) + api_client = TurniereE2E::ApiClient.new(base_url: base_url) response = api_client.login!(email: owner_email, password: owner_password) expect(response[:status]).to eq(200) @@ -66,7 +108,7 @@ RSpec.describe 'Tournament timer websocket HTTP E2E' do name: "#{name_prefix} #{SecureRandom.hex(3)}", description: 'Websocket timer tournament', public: false, - teams: Array.new(4) do |index| + teams: 4.times.map do |index| { name: "#{name_prefix.tr(' ', '')}-#{index + 1}" } end }) @@ -75,12 +117,18 @@ RSpec.describe 'Tournament timer websocket HTTP E2E' do end def wait_for_timer_state!(cable_client, timer_mode:, timeout: 20) - deadline = Time.zone.now + timeout - loop do - payload = cable_client.wait_for_timer_payload!(timeout: [deadline - Time.zone.now, 0.1].max) - return payload if payload.fetch('timer_mode') == timer_mode + wait_for_timer_payload_matching!(cable_client, timeout: timeout) do |payload| + payload.fetch('timer_mode') == timer_mode + end + end - raise "timer websocket payload not received within #{timeout}s" if Time.zone.now >= deadline + def wait_for_timer_payload_matching!(cable_client, timeout: 20) + deadline = Time.now + timeout + loop do + payload = cable_client.wait_for_timer_payload!(timeout: [deadline - Time.now, 0.1].max) + return payload if yield(payload) + + raise "timer websocket payload not received within #{timeout}s" if Time.now >= deadline end end end diff --git a/spec/factories/groups.rb b/spec/factories/groups.rb index 55cc259..ba375c5 100644 --- a/spec/factories/groups.rb +++ b/spec/factories/groups.rb @@ -11,9 +11,9 @@ FactoryBot.define do stage after(:create) do |group, evaluator| - create_list(evaluator.match_factory, evaluator.match_count, group:) + create_list(evaluator.match_factory, evaluator.match_count, group: group) group.group_scores = group.teams.map do |team| - create(:group_score, team:, group:) + create(:group_score, team: team, group: group) end end end diff --git a/spec/factories/tournaments.rb b/spec/factories/tournaments.rb index acf464f..4e2ab21 100644 --- a/spec/factories/tournaments.rb +++ b/spec/factories/tournaments.rb @@ -13,14 +13,17 @@ FactoryBot.define do intermediate_round_participants_amount { 0 } end after(:create) do |tournament, evaluator| - tournament.teams = (evaluator.teams.presence || create_list(:team, evaluator.teams_count, tournament:)) + if evaluator.teams.present? + tournament.teams = evaluator.teams + else + tournament.teams = create_list(:team, evaluator.teams_count, tournament: tournament) + end tournament.playoff_teams_amount = evaluator.playoff_teams_amount tournament.instant_finalists_amount = evaluator.instant_finalists_amount tournament.intermediate_round_participants_amount = evaluator.intermediate_round_participants_amount - if tournament.playoff_teams_amount != tournament.instant_finalists_amount + (tournament.intermediate_round_participants_amount / 2) + if tournament.playoff_teams_amount != tournament.instant_finalists_amount + tournament.intermediate_round_participants_amount / 2 raise 'playoff_teams_amount must be equal to instant_finalists_amount + intermediate_round_participants_amount / 2' end - tournament.save! end @@ -33,7 +36,7 @@ FactoryBot.define do (1..evaluator.stage_count).each do |level| tournament.stages << create( :playoff_stage, - level:, + level: level, match_count: -1, match_type: level == evaluator.stage_count ? :running_playoff_match : :empty_prepared_playoff_match ) diff --git a/spec/factories/users.rb b/spec/factories/users.rb index 9667dc8..e43b2b5 100644 --- a/spec/factories/users.rb +++ b/spec/factories/users.rb @@ -5,5 +5,6 @@ FactoryBot.define do sequence(:username) { |n| "user#{n}" } sequence(:email) { |n| "user#{n}@example.com" } password { 'password123' } + confirmed_at { Time.current } end end diff --git a/spec/interactors/add_group_stage_to_tournament_interactor_spec.rb b/spec/interactors/add_group_stage_to_tournament_interactor_spec.rb index a32e29c..1b4f90b 100644 --- a/spec/interactors/add_group_stage_to_tournament_interactor_spec.rb +++ b/spec/interactors/add_group_stage_to_tournament_interactor_spec.rb @@ -13,7 +13,7 @@ RSpec.describe AddGroupStageToTournament, type: :interactor do @empty_tournament = create(:stageless_tournament) @group_stage_tournament = create(:group_stage_tournament, stage_count: 0, group_count: 0) @group_stage = create(:group_stage) - @groups = { 1 => create_list(:team, 4), 2 => create_list(:team, 4) }.values + @groups = Hash[1 => create_list(:team, 4), 2 => create_list(:team, 4)].values @tournament_service_defaults = [78_345, 2_387] end diff --git a/spec/models/beamer_spec.rb b/spec/models/beamer_spec.rb index 287a0aa..d3e82de 100644 --- a/spec/models/beamer_spec.rb +++ b/spec/models/beamer_spec.rb @@ -14,7 +14,7 @@ RSpec.describe Beamer, type: :model do end it 'allows same name in different tournaments' do - create(:beamer, name: 'projector') + beamer = create(:beamer, name: 'projector') other = build(:beamer, name: 'projector', tournament: create(:tournament)) expect(other).to be_valid end diff --git a/spec/models/group_score_spec.rb b/spec/models/group_score_spec.rb index 422e810..a2c61aa 100644 --- a/spec/models/group_score_spec.rb +++ b/spec/models/group_score_spec.rb @@ -15,21 +15,21 @@ RSpec.describe GroupScore, type: :model do let(:team_c) { create(:team) } let(:group_score_a) do - create(:group_score, group:, team: team_a, group_points: 6, scored_points: 7, received_points: 4) + create(:group_score, group: group, team: team_a, group_points: 6, scored_points: 7, received_points: 4) end let(:group_score_b) do - create(:group_score, group:, team: team_b, group_points: 6, scored_points: 7, received_points: 4) + create(:group_score, group: group, team: team_b, group_points: 6, scored_points: 7, received_points: 4) end it 'prefers the winner of the direct comparison when the first three criteria are tied' do - create(:group_match, group:, state: :finished).tap do |match| - create(:match_score, match:, team: team_a, points: 3) - create(:match_score, match:, team: team_c, points: 1) + create(:group_match, group: group, state: :finished).tap do |match| + create(:match_score, match: match, team: team_a, points: 3) + create(:match_score, match: match, team: team_c, points: 1) end - create(:group_match, group:, state: :finished).tap do |match| - create(:match_score, match:, team: team_a, points: 4) - create(:match_score, match:, team: team_b, points: 2) + create(:group_match, group: group, state: :finished).tap do |match| + create(:match_score, match: match, team: team_a, points: 4) + create(:match_score, match: match, team: team_b, points: 2) end group_score_a.reload @@ -40,9 +40,9 @@ RSpec.describe GroupScore, type: :model do end it 'keeps the teams tied when their direct comparison was a draw' do - create(:group_match, group:, state: :finished).tap do |match| - create(:match_score, match:, team: team_a, points: 2) - create(:match_score, match:, team: team_b, points: 2) + create(:group_match, group: group, state: :finished).tap do |match| + create(:match_score, match: match, team: team_a, points: 2) + create(:match_score, match: match, team: team_b, points: 2) end group_score_a.reload @@ -59,10 +59,10 @@ RSpec.describe GroupScore, type: :model do let(:team_b) { create(:team) } let(:group_score_a) do - create(:group_score, group:, team: team_a, group_points: 6, scored_points: 7, received_points: 4) + create(:group_score, group: group, team: team_a, group_points: 6, scored_points: 7, received_points: 4) end let(:group_score_b) do - create(:group_score, group:, team: team_b, group_points: 3, scored_points: 7, received_points: 4) + create(:group_score, group: group, team: team_b, group_points: 3, scored_points: 7, received_points: 4) end it 'reports the first deciding metric when points are different' do @@ -78,7 +78,7 @@ RSpec.describe GroupScore, type: :model do it 'reports the direct comparison result when head-to-head resolves the tie' do group_score_b.update!(group_points: 6) - match = create(:group_match, group:, state: :finished).tap do |created_match| + match = create(:group_match, group: group, state: :finished).tap do |created_match| create(:match_score, match: created_match, team: team_a, points: 3) create(:match_score, match: created_match, team: team_b, points: 1) end @@ -95,7 +95,7 @@ RSpec.describe GroupScore, type: :model do it 'reports an unresolved tie when head-to-head was a draw' do group_score_b.update!(group_points: 6) - match = create(:group_match, group:, state: :finished).tap do |created_match| + match = create(:group_match, group: group, state: :finished).tap do |created_match| create(:match_score, match: created_match, team: team_a, points: 2) create(:match_score, match: created_match, team: team_b, points: 2) end @@ -112,11 +112,11 @@ RSpec.describe GroupScore, type: :model do it 'falls back to hidden points when direct comparison is still tied' do group_score_b.update!(group_points: 6) - direct_match = create(:group_match, group:, state: :finished).tap do |created_match| + direct_match = create(:group_match, group: group, state: :finished).tap do |created_match| create(:match_score, match: created_match, team: team_a, points: 2) create(:match_score, match: created_match, team: team_b, points: 2) end - decider_match = create(:group_match, group:, state: :finished, decider: true).tap do |created_match| + decider_match = create(:group_match, group: group, state: :finished, decider: true).tap do |created_match| create(:match_score, match: created_match, team: team_a, hidden_points: 3) create(:match_score, match: created_match, team: team_b, hidden_points: 1) end diff --git a/spec/models/team_spec.rb b/spec/models/team_spec.rb index d6d097e..33bcc69 100644 --- a/spec/models/team_spec.rb +++ b/spec/models/team_spec.rb @@ -9,6 +9,8 @@ RSpec.describe Team, type: :model do describe 'association' do it { should belong_to(:tournament).optional } + it { should have_many :team_players } + it { should have_many :players } it { should have_many :group_scores } it { should have_many :match_scores } it { should have_many :bets } diff --git a/spec/models/tournament_spec.rb b/spec/models/tournament_spec.rb index c613d59..21a1267 100644 --- a/spec/models/tournament_spec.rb +++ b/spec/models/tournament_spec.rb @@ -43,6 +43,40 @@ RSpec.describe Tournament, type: :model do end end + describe 'sync auth token protection' do + it 'stores follower receiver tokens only as a digest' do + tournament = create(:tournament, read_only_mode: true, sync_auth_token: 'shared-secret') + + expect(tournament.sync_auth_token).to be_nil + expect(tournament.sync_auth_token_digest).to be_present + expect(tournament.sync_token_matches?('shared-secret')).to eq(true) + expect(tournament.sync_token_matches?('wrong-secret')).to eq(false) + expect(tournament.sync_accepts_push?).to eq(true) + end + + it 'keeps leader push token available for outbound sync' do + tournament = create( + :tournament, + sync_target_url: 'https://remote.example.com/tournaments/1/sync_state', + sync_auth_token: 'shared-secret' + ) + + expect(tournament.sync_auth_token).to eq('shared-secret') + expect(tournament.sync_auth_token_digest).to be_nil + expect(tournament.sync_push_enabled?).to eq(true) + end + + it 'clears follower token digest on takeover' do + tournament = create(:tournament, read_only_mode: true, sync_auth_token: 'shared-secret') + + tournament.update!(read_only_mode: false) + + expect(tournament.sync_auth_token).to be_nil + expect(tournament.sync_auth_token_digest).to be_nil + expect(tournament.sync_auth_configured?).to eq(false) + end + end + describe '#matches' do context 'group stage tournament' do before do diff --git a/spec/models/user_spec.rb b/spec/models/user_spec.rb index 83ab130..0571095 100644 --- a/spec/models/user_spec.rb +++ b/spec/models/user_spec.rb @@ -5,6 +5,9 @@ require 'rails_helper' RSpec.describe User, type: :model do describe 'association' do it { should have_many :tournaments } + it { should have_many :team_players } + it { should have_many :player_teams } + it { should have_many :played_tournaments } it { should have_many :bets } end diff --git a/spec/rails_helper.rb b/spec/rails_helper.rb index 2277b1c..b8e1dc6 100644 --- a/spec/rails_helper.rb +++ b/spec/rails_helper.rb @@ -36,7 +36,7 @@ rescue ActiveRecord::PendingMigrationError => e end RSpec.configure do |config| # Remove this line if you're not using ActiveRecord or ActiveRecord fixtures - config.fixture_path = Rails.root.join('spec/fixtures').to_s + config.fixture_paths = ["#{::Rails.root}/spec/fixtures"] # Run only focused tests # TODO REVERT ME diff --git a/spec/requests/metrics_endpoint_spec.rb b/spec/requests/metrics_endpoint_spec.rb new file mode 100644 index 0000000..ead969f --- /dev/null +++ b/spec/requests/metrics_endpoint_spec.rb @@ -0,0 +1,28 @@ +# frozen_string_literal: true + +require 'rails_helper' + +RSpec.describe 'Metrics endpoint', type: :request do + before do + MetricsRegistry.reset! + end + + it 'renders Prometheus text without authentication' do + get '/metrics' + + expect(response).to have_http_status(:ok) + expect(response.media_type).to include('text/plain') + expect(response.body).to include('# TYPE turniere_build_info gauge') + expect(response.body).to include('# TYPE turniere_http_requests_total counter') + expect(response.body).to include('turniere_action_cable_connections 0') + end + + it 'includes request metrics from previous controller requests' do + get '/healthz' + get '/metrics' + + expect(response.body).to include( + 'turniere_http_requests_total{method="GET",controller="health",action="index",status="200"} 1' + ) + end +end diff --git a/spec/requests/team_action_list_updates_spec.rb b/spec/requests/team_action_list_updates_spec.rb new file mode 100644 index 0000000..3eae07d --- /dev/null +++ b/spec/requests/team_action_list_updates_spec.rb @@ -0,0 +1,54 @@ +# frozen_string_literal: true + +require 'rails_helper' + +RSpec.describe 'Team action list updates', type: :request do + let(:team_action_item) { create(:team_action_item) } + let(:list) { team_action_item.team_action_list } + let(:team) { team_action_item.team } + let(:owner_headers) { list.tournament.owner.create_new_auth_token } + + describe 'PATCH /team_action_items/:id' do + it 'returns not found when the item is missing' do + patch '/team_action_items/999999999', + params: { completed: true }, + headers: owner_headers, + as: :json + + expect(response).to have_http_status(:not_found) + expect(response.parsed_body.fetch('error')).to include("Couldn't find TeamActionItem") + end + + it 'updates an existing item' do + patch "/team_action_items/#{team_action_item.id}", + params: { completed: true }, + headers: owner_headers, + as: :json + + expect(response).to have_http_status(:ok) + expect(team_action_item.reload.completed).to eq(true) + end + end + + describe 'PATCH /tournaments/:tournament_id/team_action_lists/:key/teams/:team_id' do + it 'returns not found when the list key is missing' do + patch "/tournaments/#{list.tournament.id}/team_action_lists/unknown_key/teams/#{team.id}", + params: { completed: true }, + headers: owner_headers, + as: :json + + expect(response).to have_http_status(:not_found) + expect(response.parsed_body.fetch('error')).to include("Couldn't find TeamActionList") + end + + it 'returns not found when the team is missing from the list context' do + patch "/tournaments/#{list.tournament.id}/team_action_lists/#{list.key}/teams/999999999", + params: { completed: true }, + headers: owner_headers, + as: :json + + expect(response).to have_http_status(:not_found) + expect(response.parsed_body.fetch('error')).to include("Couldn't find TeamActionItem") + end + end +end diff --git a/spec/routing/accounts_routing_spec.rb b/spec/routing/accounts_routing_spec.rb new file mode 100644 index 0000000..a6f008d --- /dev/null +++ b/spec/routing/accounts_routing_spec.rb @@ -0,0 +1,11 @@ +# frozen_string_literal: true + +require 'rails_helper' + +RSpec.describe AccountsController, type: :routing do + describe 'routing' do + it 'routes to #show' do + expect(get: '/accounts/1').to route_to('accounts#show', id: '1') + end + end +end diff --git a/spec/routing/matches_routing_spec.rb b/spec/routing/matches_routing_spec.rb index ac3a659..b832c37 100644 --- a/spec/routing/matches_routing_spec.rb +++ b/spec/routing/matches_routing_spec.rb @@ -15,5 +15,9 @@ RSpec.describe MatchesController, type: :routing do it 'routes to #swap' do expect(patch: '/matches/1/swap').to route_to('matches#swap', id: '1') end + + it 'routes nested tournament match metadata updates' do + expect(patch: '/tournaments/1/matches/metadata').to route_to('matches#metadata', tournament_id: '1') + end end end diff --git a/spec/routing/metrics_routing_spec.rb b/spec/routing/metrics_routing_spec.rb new file mode 100644 index 0000000..704ed59 --- /dev/null +++ b/spec/routing/metrics_routing_spec.rb @@ -0,0 +1,11 @@ +# frozen_string_literal: true + +require 'rails_helper' + +RSpec.describe MetricsController, type: :routing do + describe 'routing' do + it 'routes to #index' do + expect(get: '/metrics').to route_to('metrics#index') + end + end +end diff --git a/spec/routing/tournaments_routing_spec.rb b/spec/routing/tournaments_routing_spec.rb index 5cdb087..77b85bb 100644 --- a/spec/routing/tournaments_routing_spec.rb +++ b/spec/routing/tournaments_routing_spec.rb @@ -28,12 +28,26 @@ RSpec.describe TournamentsController, type: :routing do expect(delete: '/tournaments/1').to route_to('tournaments#destroy', id: '1') end + it 'routes to #destroy_playoffs' do + expect(delete: '/tournaments/1/playoffs').to route_to('tournaments#destroy_playoffs', id: '1') + end + it 'routes to #sync_state' do expect(patch: '/tournaments/1/sync_state').to route_to('tournaments#sync_state', id: '1') end + it 'routes to #start_next_group_matches' do + expect(patch: '/tournaments/1/start_next_group_matches').to( + route_to('tournaments#start_next_group_matches', id: '1') + ) + end + it 'routes to #test_sync' do expect(post: '/tournaments/1/test_sync').to route_to('tournaments#test_sync', id: '1') end + + it 'routes to transaction log entries' do + expect(get: '/tournaments/1/transaction_log').to route_to('tournament_transaction_log_entries#index', id: '1') + end end end diff --git a/spec/services/group_stage_service_spec.rb b/spec/services/group_stage_service_spec.rb index 0b0a21e..972009d 100644 --- a/spec/services/group_stage_service_spec.rb +++ b/spec/services/group_stage_service_spec.rb @@ -4,7 +4,7 @@ RSpec.describe GroupStageService do before do @teams1 = create_list(:team, 4) @teams2 = create_list(:team, 4) - @prepared_groups = { 1 => @teams1, 2 => @teams2 }.values + @prepared_groups = Hash[1 => @teams1, 2 => @teams2].values end describe '#generate_group_stage' do let(:prepared_groups_groupstage) do @@ -52,7 +52,7 @@ RSpec.describe GroupStageService do end end - # TODO: test get_advancing_teams when test data for running group stage is ready + # TODO test get_advancing_teams when test data for running group stage is ready describe '#get_group_object_from' do it 'returns a group' do @@ -205,13 +205,13 @@ RSpec.describe GroupStageService do team_a = create(:team) team_b = create(:team) - create(:group_match, group:, state: :finished).tap do |match| - create(:match_score, match:, team: team_a, points: 4) - create(:match_score, match:, team: team_b, points: 2) + create(:group_match, group: group, state: :finished).tap do |match| + create(:match_score, match: match, team: team_a, points: 4) + create(:match_score, match: match, team: team_b, points: 2) end - create(:group_score, group:, team: team_a, group_points: 3, scored_points: 4, received_points: 2) - create(:group_score, group:, team: team_b, group_points: 3, scored_points: 4, received_points: 2) + create(:group_score, group: group, team: team_a, group_points: 3, scored_points: 4, received_points: 2) + create(:group_score, group: group, team: team_b, group_points: 3, scored_points: 4, received_points: 2) expect(GroupStageService.teams_sorted_by_group_scores(group.reload)).to eq([team_a, team_b]) end @@ -224,14 +224,14 @@ RSpec.describe GroupStageService do playoff_teams_amount: 2, instant_finalists_amount: 2, intermediate_round_participants_amount: 0, - group_stage:) + group_stage: group_stage) end let(:group_stage) { create(:group_stage, tournament: create(:tournament, teams_count: 0), group_count: 0) } let(:group) { create(:group, stage: group_stage, match_count: 0, number: 1) } - let(:team_a) { create(:team, tournament:, name: 'A') } - let(:team_b) { create(:team, tournament:, name: 'B') } - let(:team_c) { create(:team, tournament:, name: 'C') } - let(:team_d) { create(:team, tournament:, name: 'D') } + let(:team_a) { create(:team, tournament: tournament, name: 'A') } + let(:team_b) { create(:team, tournament: tournament, name: 'B') } + let(:team_c) { create(:team, tournament: tournament, name: 'C') } + let(:team_d) { create(:team, tournament: tournament, name: 'D') } before do group_stage.groups = [group] @@ -240,13 +240,13 @@ RSpec.describe GroupStageService do end it 'returns direct-comparison metadata for teams separated by head-to-head' do - create(:group_match, group:, state: :finished).tap do |match| - create(:match_score, match:, team: team_a, points: 4) - create(:match_score, match:, team: team_b, points: 2) + create(:group_match, group: group, state: :finished).tap do |match| + create(:match_score, match: match, team: team_a, points: 4) + create(:match_score, match: match, team: team_b, points: 2) end - score_a = create(:group_score, group:, team: team_a, group_points: 3, scored_points: 4, received_points: 2) - score_b = create(:group_score, group:, team: team_b, group_points: 3, scored_points: 4, received_points: 2) + score_a = create(:group_score, group: group, team: team_a, group_points: 3, scored_points: 4, received_points: 2) + score_b = create(:group_score, group: group, team: team_b, group_points: 3, scored_points: 4, received_points: 2) GroupStageService.send(:recalculate_position_of_group_scores!, [score_a, score_b]).each(&:save!) @@ -269,18 +269,17 @@ RSpec.describe GroupStageService do end it 'keeps shared positions and requests a decider when an unresolved tie affects advancing placements' do - tournament.update!(playoff_teams_amount: 1, instant_finalists_amount: 1, - intermediate_round_participants_amount: 0) + tournament.update!(playoff_teams_amount: 1, instant_finalists_amount: 1, intermediate_round_participants_amount: 0) - create(:group_match, group:, state: :finished).tap do |match| - create(:match_score, match:, team: team_a, points: 2) - create(:match_score, match:, team: team_b, points: 2) + create(:group_match, group: group, state: :finished).tap do |match| + create(:match_score, match: match, team: team_a, points: 2) + create(:match_score, match: match, team: team_b, points: 2) end - score_a = create(:group_score, group:, team: team_a, group_points: 7, scored_points: 4, received_points: 1) - score_b = create(:group_score, group:, team: team_b, group_points: 7, scored_points: 4, received_points: 1) - score_c = create(:group_score, group:, team: team_c, group_points: 3, scored_points: 2, received_points: 3) - score_d = create(:group_score, group:, team: team_d, group_points: 0, scored_points: 1, received_points: 7) + score_a = create(:group_score, group: group, team: team_a, group_points: 7, scored_points: 4, received_points: 1) + score_b = create(:group_score, group: group, team: team_b, group_points: 7, scored_points: 4, received_points: 1) + score_c = create(:group_score, group: group, team: team_c, group_points: 3, scored_points: 2, received_points: 3) + score_d = create(:group_score, group: group, team: team_d, group_points: 0, scored_points: 1, received_points: 7) GroupStageService.send(:recalculate_position_of_group_scores!, [score_a, score_b, score_c, score_d]).each(&:save!) @@ -302,18 +301,17 @@ RSpec.describe GroupStageService do end it 'requests a decider when tied teams both advance but their seeding stays unresolved' do - tournament.update!(playoff_teams_amount: 2, instant_finalists_amount: 2, - intermediate_round_participants_amount: 0) + tournament.update!(playoff_teams_amount: 2, instant_finalists_amount: 2, intermediate_round_participants_amount: 0) - create(:group_match, group:, state: :finished).tap do |match| - create(:match_score, match:, team: team_a, points: 2) - create(:match_score, match:, team: team_b, points: 2) + create(:group_match, group: group, state: :finished).tap do |match| + create(:match_score, match: match, team: team_a, points: 2) + create(:match_score, match: match, team: team_b, points: 2) end - score_a = create(:group_score, group:, team: team_a, group_points: 7, scored_points: 4, received_points: 1) - score_b = create(:group_score, group:, team: team_b, group_points: 7, scored_points: 4, received_points: 1) - score_c = create(:group_score, group:, team: team_c, group_points: 3, scored_points: 2, received_points: 3) - score_d = create(:group_score, group:, team: team_d, group_points: 0, scored_points: 1, received_points: 7) + score_a = create(:group_score, group: group, team: team_a, group_points: 7, scored_points: 4, received_points: 1) + score_b = create(:group_score, group: group, team: team_b, group_points: 7, scored_points: 4, received_points: 1) + score_c = create(:group_score, group: group, team: team_c, group_points: 3, scored_points: 2, received_points: 3) + score_d = create(:group_score, group: group, team: team_d, group_points: 0, scored_points: 1, received_points: 7) GroupStageService.send(:recalculate_position_of_group_scores!, [score_a, score_b, score_c, score_d]).each(&:save!) @@ -335,18 +333,17 @@ RSpec.describe GroupStageService do end it 'does not expose unresolved tie metadata while regular matches in the group are still unfinished' do - tournament.update!(playoff_teams_amount: 1, instant_finalists_amount: 1, - intermediate_round_participants_amount: 0) + tournament.update!(playoff_teams_amount: 1, instant_finalists_amount: 1, intermediate_round_participants_amount: 0) - create(:group_match, group:, state: :in_progress).tap do |match| - create(:match_score, match:, team: team_a, points: 2) - create(:match_score, match:, team: team_b, points: 2) + create(:group_match, group: group, state: :in_progress).tap do |match| + create(:match_score, match: match, team: team_a, points: 2) + create(:match_score, match: match, team: team_b, points: 2) end - score_a = create(:group_score, group:, team: team_a, group_points: 1, scored_points: 2, received_points: 2) - score_b = create(:group_score, group:, team: team_b, group_points: 1, scored_points: 2, received_points: 2) - score_c = create(:group_score, group:, team: team_c, group_points: 0, scored_points: 0, received_points: 0) - score_d = create(:group_score, group:, team: team_d, group_points: 0, scored_points: 0, received_points: 0) + score_a = create(:group_score, group: group, team: team_a, group_points: 1, scored_points: 2, received_points: 2) + score_b = create(:group_score, group: group, team: team_b, group_points: 1, scored_points: 2, received_points: 2) + score_c = create(:group_score, group: group, team: team_c, group_points: 0, scored_points: 0, received_points: 0) + score_d = create(:group_score, group: group, team: team_d, group_points: 0, scored_points: 0, received_points: 0) GroupStageService.send(:recalculate_position_of_group_scores!, [score_a, score_b, score_c, score_d]).each(&:save!) @@ -355,15 +352,12 @@ RSpec.describe GroupStageService do end it 'does not expose unresolved tie metadata outside the advancing cutoff before the group is finished' do - tournament.update!(playoff_teams_amount: 2, instant_finalists_amount: 2, - intermediate_round_participants_amount: 0) + tournament.update!(playoff_teams_amount: 2, instant_finalists_amount: 2, intermediate_round_participants_amount: 0) - score_a = create(:group_score, group:, team: team_a, group_points: 9, scored_points: 5, received_points: 1, - position: 1) - score_b = create(:group_score, group:, team: team_b, group_points: 6, scored_points: 4, received_points: 2, - position: 2) - score_c = create(:group_score, group:, team: team_c, group_points: 3, scored_points: 2, received_points: 3) - score_d = create(:group_score, group:, team: team_d, group_points: 3, scored_points: 2, received_points: 3) + score_a = create(:group_score, group: group, team: team_a, group_points: 9, scored_points: 5, received_points: 1, position: 1) + score_b = create(:group_score, group: group, team: team_b, group_points: 6, scored_points: 4, received_points: 2, position: 2) + score_c = create(:group_score, group: group, team: team_c, group_points: 3, scored_points: 2, received_points: 3) + score_d = create(:group_score, group: group, team: team_d, group_points: 3, scored_points: 2, received_points: 3) GroupStageService.send(:recalculate_position_of_group_scores!, [score_a, score_b, score_c, score_d]).each(&:save!) @@ -373,22 +367,21 @@ RSpec.describe GroupStageService do end it 'uses the decider hidden points to resolve a cutoff tie' do - tournament.update!(playoff_teams_amount: 1, instant_finalists_amount: 1, - intermediate_round_participants_amount: 0) + tournament.update!(playoff_teams_amount: 1, instant_finalists_amount: 1, intermediate_round_participants_amount: 0) - create(:group_match, group:, state: :finished).tap do |match| - create(:match_score, match:, team: team_a, points: 2) - create(:match_score, match:, team: team_b, points: 2) + create(:group_match, group: group, state: :finished).tap do |match| + create(:match_score, match: match, team: team_a, points: 2) + create(:match_score, match: match, team: team_b, points: 2) end - create(:group_match, group:, state: :finished, decider: true).tap do |match| - create(:match_score, match:, team: team_a, hidden_points: 5) - create(:match_score, match:, team: team_b, hidden_points: 3) + create(:group_match, group: group, state: :finished, decider: true).tap do |match| + create(:match_score, match: match, team: team_a, hidden_points: 5) + create(:match_score, match: match, team: team_b, hidden_points: 3) end - score_a = create(:group_score, group:, team: team_a, group_points: 7, scored_points: 4, received_points: 1) - score_b = create(:group_score, group:, team: team_b, group_points: 7, scored_points: 4, received_points: 1) - score_c = create(:group_score, group:, team: team_c, group_points: 3, scored_points: 2, received_points: 3) - score_d = create(:group_score, group:, team: team_d, group_points: 0, scored_points: 1, received_points: 7) + score_a = create(:group_score, group: group, team: team_a, group_points: 7, scored_points: 4, received_points: 1) + score_b = create(:group_score, group: group, team: team_b, group_points: 7, scored_points: 4, received_points: 1) + score_c = create(:group_score, group: group, team: team_c, group_points: 3, scored_points: 2, received_points: 3) + score_d = create(:group_score, group: group, team: team_d, group_points: 0, scored_points: 1, received_points: 7) GroupStageService.send(:recalculate_position_of_group_scores!, [score_a, score_b, score_c, score_d]).each(&:save!) @@ -424,7 +417,7 @@ RSpec.describe GroupStageService do @tournament = create(:prepared_group_stage_tournament, group_stage: @group_stage, - teams:, + teams: teams, playoff_teams_amount: 16, instant_finalists_amount: 16, intermediate_round_participants_amount: 0) @@ -434,7 +427,7 @@ RSpec.describe GroupStageService do match.match_scores.each do |ms| # give the team 10 points minus the number in their name # this results in the team 0 always winning and getting to place 1 in the group etc. - ms.points = 10 - ms.team.name.split.last.to_i + ms.points = 10 - ms.team.name.split(' ').last.to_i ms.save! end match.state = 'finished' @@ -457,7 +450,7 @@ RSpec.describe GroupStageService do advancing_teams.each_with_index do |team, i| # if index is even, the team should be of a first place; end in a 0 # if index is odd, the team should be of a second place; end in a 1 - team_quality = team.name.split.last.to_i + team_quality = team.name.split(' ').last.to_i expect(team_quality % 2).to be(i % 2) end end @@ -476,9 +469,9 @@ RSpec.describe GroupStageService do advancing_teams = GroupStageService.get_advancing_teams(@group_stage) advancing_teams.each_slice(2).to_a.each do |matchup| # this is the team that landed a first place in the group - first_place_team = matchup[0].name.split[1].to_i + first_place_team = matchup[0].name.split(' ')[1].to_i # this is the team that landed a second place in the group - second_place_team = matchup[1].name.split[1].to_i + second_place_team = matchup[1].name.split(' ')[1].to_i expect(group_first_matchups_expected[first_place_team]).to eq(second_place_team) end end diff --git a/spec/services/match_service_spec.rb b/spec/services/match_service_spec.rb index 4dc1b18..20dc4b2 100644 --- a/spec/services/match_service_spec.rb +++ b/spec/services/match_service_spec.rb @@ -62,7 +62,7 @@ RSpec.describe MatchService do first_team = match.match_scores.first.team.name second_team = match.match_scores.second.team.name expect(first_team).to eq(teams[2 * index].name) - expect(second_team).to eq(teams[(2 * index) + 1].name) + expect(second_team).to eq(teams[2 * index + 1].name) end end end diff --git a/spec/services/metrics_registry_spec.rb b/spec/services/metrics_registry_spec.rb new file mode 100644 index 0000000..a557250 --- /dev/null +++ b/spec/services/metrics_registry_spec.rb @@ -0,0 +1,59 @@ +# frozen_string_literal: true + +require 'rails_helper' + +RSpec.describe MetricsRegistry do + let(:health_labels) { 'method="GET",controller="health",action="index",status="200"' } + + before do + described_class.reset! + end + + describe '.record_http_request' do + it 'renders Prometheus counters and duration histogram buckets' do + described_class.record_http_request( + method: 'get', + controller: 'health', + action: 'index', + status: 200, + duration: 0.012 + ) + + metrics = described_class.render_prometheus + + expect(metrics).to include( + "turniere_http_requests_total{#{health_labels}} 1" + ) + expect(metrics).to include( + "turniere_http_request_duration_seconds_bucket{#{health_labels},le=\"0.025\"} 1" + ) + expect(metrics).to include( + "turniere_http_request_duration_seconds_count{#{health_labels}} 1" + ) + expect(metrics).to match( + /turniere_http_request_duration_seconds_sum\{#{health_labels}\} 0\.012/ + ) + end + end + + describe 'ActionCable connection metrics' do + it 'tracks open and total websocket connections' do + described_class.action_cable_connected + described_class.action_cable_connected + described_class.action_cable_disconnected + + metrics = described_class.render_prometheus + + expect(metrics).to include("turniere_action_cable_connections 1\n") + expect(metrics).to include("turniere_action_cable_connections_total 2\n") + end + + it 'does not let open websocket connections become negative' do + described_class.action_cable_disconnected + + metrics = described_class.render_prometheus + + expect(metrics).to include("turniere_action_cable_connections 0\n") + end + end +end diff --git a/spec/services/playoff_stage_service_spec.rb b/spec/services/playoff_stage_service_spec.rb index c2197d2..351e5fa 100644 --- a/spec/services/playoff_stage_service_spec.rb +++ b/spec/services/playoff_stage_service_spec.rb @@ -61,7 +61,7 @@ RSpec.describe PlayoffStageService do { team_size: 9, expected_amount_of_playoff_stages: 4 }, { team_size: 10, expected_amount_of_playoff_stages: 4 }, { team_size: 16, expected_amount_of_playoff_stages: 4 }, - { team_size: 24, expected_amount_of_playoff_stages: 5 } + { team_size: 24, expected_amount_of_playoff_stages: 5 }, ].each do |parameters| it "generates playoff stages for #{parameters[:team_size]} teams" do amount_of_teams = parameters[:team_size] diff --git a/spec/services/team_action_list_service_spec.rb b/spec/services/team_action_list_service_spec.rb index 22328c6..f71d4a1 100644 --- a/spec/services/team_action_list_service_spec.rb +++ b/spec/services/team_action_list_service_spec.rb @@ -48,8 +48,7 @@ RSpec.describe TeamActionListService do action_name: 'Break', source: 'unknown_source' ) - end.to raise_error(TeamActionListService::InvalidSourceError, - 'Unsupported team action list source: unknown_source') + end.to raise_error(TeamActionListService::InvalidSourceError, 'Unsupported team action list source: unknown_source') end it 'raises when source resolves to no teams' do @@ -63,8 +62,7 @@ RSpec.describe TeamActionListService do action_name: 'Nothing', source: 'all_tournament_teams' ) - end.to raise_error(TeamActionListService::EmptySourceError, - 'Source all_tournament_teams does not currently contain any teams') + end.to raise_error(TeamActionListService::EmptySourceError, 'Source all_tournament_teams does not currently contain any teams') end end end diff --git a/spec/services/tournament_group_match_starter_spec.rb b/spec/services/tournament_group_match_starter_spec.rb new file mode 100644 index 0000000..1c0f662 --- /dev/null +++ b/spec/services/tournament_group_match_starter_spec.rb @@ -0,0 +1,73 @@ +# frozen_string_literal: true + +require 'rails_helper' + +RSpec.describe TournamentGroupMatchStarter do + let(:tournament) { create(:group_stage_tournament, group_count: 4, match_factory: :filled_group_match) } + let(:groups) { tournament.group_stage.groups.sort_by(&:number) } + + before do + groups.each { |group| set_group_match_states(group, Array.new(4, :not_started)) } + end + + describe '.upcoming_matches' do + it 'returns only groups whose next unstarted match is at the lowest pending position' do + start_group_positions(groups[0], [0, 1, 2]) + start_group_positions(groups[1], [0, 1]) + start_group_positions(groups[2], [0, 1]) + start_group_positions(groups[3], [0, 1]) + + matches = described_class.upcoming_matches(tournament) + + expect(matches.map(&:group)).to match_array(groups.drop(1)) + expect(matches.map(&:position).uniq).to eq([2]) + end + end + + describe '.start_next!' do + it 'starts only a single lagging group before all groups can advance' do + start_group_positions(groups[0], [0, 1]) + start_group_positions(groups[1], [0]) + start_group_positions(groups[2], [0, 1]) + start_group_positions(groups[3], [0, 1]) + + first_start = described_class.start_next!(tournament, logger: Logger.new(nil)) + second_start = described_class.start_next!(tournament, logger: Logger.new(nil)) + + expect_group_positions(first_start, [[groups[1], 1]]) + expect_group_positions(second_start, groups.map { |group| [group, 2] }) + end + + it 'starts catch-up waves before the next position is started everywhere' do + start_group_positions(groups[0], [0, 1, 2]) + start_group_positions(groups[1], [0]) + start_group_positions(groups[2], [0, 1, 2]) + start_group_positions(groups[3], [0, 1]) + + first_start = described_class.start_next!(tournament, logger: Logger.new(nil)) + second_start = described_class.start_next!(tournament, logger: Logger.new(nil)) + third_start = described_class.start_next!(tournament, logger: Logger.new(nil)) + + expect_group_positions(first_start, [[groups[1], 1]]) + expect_group_positions(second_start, [[groups[1], 2], [groups[3], 2]]) + expect_group_positions(third_start, groups.map { |group| [group, 3] }) + 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 + + def start_group_positions(group, positions) + positions.each do |position| + group.matches.find_by!(position:).update!(state: :in_progress) + end + end + + def expect_group_positions(matches, expected_pairs) + expect(matches.map { |match| [match.group, match.position] }).to match_array(expected_pairs) + expect(matches.map { |match| match.reload.state }.uniq).to eq(['in_progress']) + end +end diff --git a/spec/services/tournament_live_payload_spec.rb b/spec/services/tournament_live_payload_spec.rb index 1e6a899..7cb435f 100644 --- a/spec/services/tournament_live_payload_spec.rb +++ b/spec/services/tournament_live_payload_spec.rb @@ -26,12 +26,17 @@ RSpec.describe 'Tournament live payloads' do payload = described_class.build(tournament) serialized_match = payload.find { |candidate| candidate[:id] == match.id } - expect(serialized_match.keys).to match_array(%i[id position state teams match_scores group stage]) + expect(serialized_match.keys).to match_array( + %i[id position state location start_time notes teams match_scores group stage] + ) expect(serialized_match[:state]).to eq(match.state) expect(serialized_match[:position]).to eq(match.position) + expect(serialized_match[:location]).to eq(match.location) + expect(serialized_match[:start_time]).to eq(match.start_time) + expect(serialized_match[:notes]).to eq(match.notes) expect(serialized_match.dig(:group, :id)).to eq(match.group_id) expect(serialized_match.dig(:stage, :id)).to eq(match.group.stage_id) - expect(serialized_match[:teams].pluck(:id)).to match_array(match.teams.map(&:id)) + expect(serialized_match[:teams].map { |team| team[:id] }).to match_array(match.teams.map(&:id)) expect(serialized_match[:match_scores].map { |score| score[:team][:id] }).to match_array(match.teams.map(&:id)) end @@ -49,6 +54,21 @@ RSpec.describe 'Tournament live payloads' do expect(matches.map(&:group_id)).to match_array(tournament.group_stage.groups.map(&:id)) end + it 'returns only catch-up group matches for upcoming state' do + tournament = create(:group_stage_tournament, group_count: 4, match_factory: :filled_group_match) + groups = tournament.group_stage.groups.sort_by(&:number) + + set_group_match_states(groups[0], %i[finished finished finished in_progress]) + groups.drop(1).each do |group| + set_group_match_states(group, %i[finished finished in_progress not_started]) + end + + matches = described_class.matches_for(tournament, state: 'upcoming') + + expect(matches.map(&:group_id)).to match_array(groups.drop(1).map(&:id)) + expect(matches.map(&:position).uniq).to eq([3]) + end + it 'filters all matches belonging to a stage level' do tournament = create(:stage_tournament, stage_count: 2) stage = tournament.stages.find_by!(level: 1) @@ -83,4 +103,33 @@ RSpec.describe 'Tournament live payloads' do expect(payload[:matches]).to eq(TournamentMatchesPayload.build(tournament, state: 'not_started')) end end + + describe TournamentTimerBroadcastPayload do + it 'serializes timer reason fields for websocket subscribers' do + tournament = create( + :tournament, + timestamp: Time.utc(2026, 4, 24, 10, 15, 0), + timer_mode: 'countdown', + timer_reason: 'custom', + timer_reason_text: 'Opening ceremony' + ) + + payload = described_class.build(tournament) + + expect(payload).to include( + type: 'timer.updated', + tournament_id: tournament.id, + timestamp: '2026-04-24T10:15:00Z', + timer_mode: 'countdown', + timer_reason: 'custom', + timer_reason_text: 'Opening ceremony' + ) + end + end + + 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 diff --git a/spec/services/tournament_snapshot_importer_spec.rb b/spec/services/tournament_snapshot_importer_spec.rb index d333306..7ade9b7 100644 --- a/spec/services/tournament_snapshot_importer_spec.rb +++ b/spec/services/tournament_snapshot_importer_spec.rb @@ -6,6 +6,21 @@ RSpec.describe TournamentSnapshotImporter do describe '.import!' do it 'replaces follower tournament graph using source ids as stable mapping keys' do source = create(:group_stage_tournament, group_count: 2, match_factory: :filled_group_match) + source.update!( + location: 'Court 4', + starts_at: Time.utc(2026, 4, 24, 9, 0, 0), + ends_at: Time.utc(2026, 4, 24, 18, 30, 0), + website_url: 'https://example.com/sync-cup', + timestamp: Time.utc(2026, 4, 24, 10, 15, 0), + timer_mode: 'countdown', + timer_reason: 'custom', + timer_reason_text: 'Opening ceremony' + ) + source.matches.first.update!( + location: 'Main Hall', + start_time: '17:00', + notes: 'Opening match' + ) source.beamers.create!( name: 'stream-a', display_state: 'during_game', @@ -17,24 +32,31 @@ RSpec.describe TournamentSnapshotImporter do snapshot = TournamentSnapshotBuilder.build(source) - described_class.import!(tournament: follower, snapshot:) + described_class.import!(tournament: follower, snapshot: snapshot) follower.reload expect(follower.sync_source_tournament_id).to eq(source.id) expect(follower.name).to eq(source.name) + expect(follower.location).to eq(source.location) + expect(follower.starts_at).to eq(source.starts_at) + expect(follower.ends_at).to eq(source.ends_at) + expect(follower.website_url).to eq(source.website_url) expect(follower.timer_mode).to eq(source.timer_mode) + expect(follower.timer_reason).to eq(source.timer_reason) + expect(follower.timer_reason_text).to eq(source.timer_reason_text) expect(follower.teams.count).to eq(snapshot.fetch(:teams).count) expect(follower.beamers.count).to eq(snapshot.fetch(:beamers).count) expect(follower.team_action_lists.count).to eq(snapshot.fetch(:team_action_lists).count) expect(follower.team_action_lists.first.team_action_items.count).to eq(snapshot.fetch(:team_action_items).count) expect(follower.stages.count).to eq(source.stages.count) expect(follower.matches.count).to eq(source.matches.count) - expect(follower.teams.pluck(:sync_source_id)).to match_array(snapshot.fetch(:teams).map do |team| - team.fetch(:id) - end) - expect(follower.beamers.pluck(:sync_source_id)).to match_array(snapshot.fetch(:beamers).map do |beamer| - beamer.fetch(:id) - end) + expect(follower.matches.find { |match| match.sync_source_id == source.matches.first.id }).to have_attributes( + location: 'Main Hall', + start_time: '17:00', + notes: 'Opening match' + ) + expect(follower.teams.pluck(:sync_source_id)).to match_array(snapshot.fetch(:teams).map { |team| team.fetch(:id) }) + expect(follower.beamers.pluck(:sync_source_id)).to match_array(snapshot.fetch(:beamers).map { |beamer| beamer.fetch(:id) }) end it 'ignores stale snapshots that arrive after a newer one' do @@ -62,7 +84,7 @@ RSpec.describe TournamentSnapshotImporter do snapshot = TournamentSnapshotBuilder.build(source) - described_class.import!(tournament: follower, snapshot:) + described_class.import!(tournament: follower, snapshot: snapshot) follower.reload expect(follower.id).not_to eq(source.id) @@ -70,12 +92,13 @@ RSpec.describe TournamentSnapshotImporter do expect(follower.teams.count).to eq(128) expect(follower.stages.where(level: -1).first.groups.count).to eq(32) expect(follower.stages.where('level >= 0').sum { |stage| stage.matches.count }).to eq(64) - expect(follower.matches.count(&:decider?)).to eq(source.matches.count(&:decider?)) + expect(follower.matches.select(&:decider?).count).to eq(source.matches.select(&:decider?).count) expect(follower.teams.pluck(:sync_source_id)).to match_array(source.teams.pluck(:id)) expect(source.teams.pluck(:id) & follower.teams.pluck(:id)).not_to match_array(source.teams.pluck(:id)) end end + # rubocop:disable Metrics/MethodLength def create_large_source_tournament! tournament = create( :tournament, @@ -100,15 +123,15 @@ RSpec.describe TournamentSnapshotImporter do config: { image_key: 'bpwstr_logo' } ) - group_stage = Stage.create!(tournament:, level: -1, state: :finished) + group_stage = Stage.create!(tournament: tournament, level: -1, state: :finished) advancing_teams = [] 32.times do |group_index| group = Group.create!(stage: group_stage, number: group_index + 1) - teams = Array.new(4) do |seed_index| + teams = 4.times.map do |seed_index| create( :team, - tournament:, + tournament: tournament, name: "Leader-G#{group_index + 1}-S#{seed_index + 1}" ) end @@ -116,21 +139,34 @@ RSpec.describe TournamentSnapshotImporter do teams[0].update!(name: "#{teams[0].name} Renamed") if group_index.zero? teams[1].update!(name: "#{teams[1].name} Renamed") if group_index == 15 - create_group_round_robin!(group:, teams:) - create_group_scores!(group:, teams:) + create_group_round_robin!(group: group, teams: teams) + create_group_scores!(group: group, teams: teams) - create_decider_match!(group:, teams: teams.take(2), hidden_points: [8 - group_index, 4 + group_index]) if group_index < 3 + if group_index < 3 + create_decider_match!(group: group, teams: teams.take(2), hidden_points: [8 - group_index, 4 + group_index]) + end advancing_teams.concat(teams.take(2)) end - result = AddPlayoffsToTournamentAndSave.call(tournament:, teams: advancing_teams) + result = AddPlayoffsToTournamentAndSave.call(tournament: tournament, teams: advancing_teams) expect(result).to be_a_success tournament.reload play_some_playoff_matches!(tournament) + tournament.matches.min_by(&:id).update!( + location: 'Feature Court', + start_time: '18:30', + notes: 'Follower sync metadata fixture' + ) + tournament.update!( + timestamp: Time.utc(2026, 4, 24, 10, 15, 0), + timer_mode: 'countdown', + timer_reason: 'playoff_start' + ) tournament.reload end + # rubocop:enable Metrics/MethodLength def create_group_round_robin!(group:, teams:) pairings = [ @@ -143,23 +179,23 @@ RSpec.describe TournamentSnapshotImporter do ] pairings.each_with_index do |(left_index, right_index, left_points, right_points), position| - match = Match.create!(group:, state: :finished, position:, decider: false) - MatchScore.create!(match:, team: teams[left_index], points: left_points, hidden_points: 0) - MatchScore.create!(match:, team: teams[right_index], points: right_points, hidden_points: 0) + match = Match.create!(group: group, state: :finished, position: position, decider: false) + MatchScore.create!(match: match, team: teams[left_index], points: left_points, hidden_points: 0) + MatchScore.create!(match: match, team: teams[right_index], points: right_points, hidden_points: 0) end end def create_decider_match!(group:, teams:, hidden_points:) - match = Match.create!(group:, state: :finished, position: 6, decider: true) - MatchScore.create!(match:, team: teams.first, points: 0, hidden_points: hidden_points.first) - MatchScore.create!(match:, team: teams.second, points: 0, hidden_points: hidden_points.last) + match = Match.create!(group: group, state: :finished, position: 6, decider: true) + MatchScore.create!(match: match, team: teams.first, points: 0, hidden_points: hidden_points.first) + MatchScore.create!(match: match, team: teams.second, points: 0, hidden_points: hidden_points.last) end def create_group_scores!(group:, teams:) teams.each_with_index do |team, index| GroupScore.create!( - group:, - team:, + group: group, + team: team, group_points: 9 - index, scored_points: 12 - index, received_points: index, @@ -179,16 +215,23 @@ RSpec.describe TournamentSnapshotImporter do end end + # rubocop:disable Metrics/MethodLength def sync_signature(tournament) { name: tournament.name, public: tournament.public, + location: tournament.location, + starts_at: tournament.starts_at&.iso8601, + ends_at: tournament.ends_at&.iso8601, + website_url: tournament.website_url, playoff_teams_amount: tournament.playoff_teams_amount, instant_finalists_amount: tournament.instant_finalists_amount, intermediate_round_participants_amount: tournament.intermediate_round_participants_amount, timestamp: tournament.timestamp&.iso8601, timer_mode: tournament.timer_mode, - teams: tournament.teams.order(:name).map(&:name), + timer_reason: tournament.timer_reason, + timer_reason_text: tournament.timer_reason_text, + teams: tournament.teams.order(:name).map { |team| team.name }, team_action_lists: tournament.team_action_lists.order(:key).map do |list| { key: list.key, @@ -231,12 +274,16 @@ RSpec.describe TournamentSnapshotImporter do end } end + # rubocop:enable Metrics/MethodLength def match_signature(match) { state: match.state, position: match.position, decider: match.decider, + location: match.location, + start_time: match.start_time, + notes: match.notes, scores: match.match_scores.includes(:team).sort_by { |score| score.team.name }.map do |score| { team_name: score.team.name, diff --git a/spec/services/tournament_sync_enqueue_spec.rb b/spec/services/tournament_sync_enqueue_spec.rb index 7b9d103..77cc3ad 100644 --- a/spec/services/tournament_sync_enqueue_spec.rb +++ b/spec/services/tournament_sync_enqueue_spec.rb @@ -9,7 +9,7 @@ RSpec.describe TournamentSyncEnqueue do sync_target_url: 'https://remote.example.com/tournaments/1/sync_state', sync_auth_token: 'shared-secret') entry = TournamentSyncQueueEntry.create!( - tournament:, + tournament: tournament, snapshot_json: '{"old":true}', status: 'pending', attempts: 4, diff --git a/spec/services/tournament_sync_processor_spec.rb b/spec/services/tournament_sync_processor_spec.rb index 94445c6..f3b54ec 100644 --- a/spec/services/tournament_sync_processor_spec.rb +++ b/spec/services/tournament_sync_processor_spec.rb @@ -9,10 +9,9 @@ RSpec.describe TournamentSyncProcessor do sync_target_url: 'https://remote.example.com/tournaments/1/sync_state', sync_auth_token: 'shared-secret') TournamentSyncEnqueue.call(tournament) - entry = TournamentSyncQueueEntry.find_by!(tournament:) + entry = TournamentSyncQueueEntry.find_by!(tournament: tournament) - allow(TournamentSyncPusher).to receive(:push_snapshot!).and_raise(TournamentSyncPusher::SyncFailed, - 'network down') + allow(TournamentSyncPusher).to receive(:push_snapshot!).and_raise(TournamentSyncPusher::SyncFailed, 'network down') described_class.process_due! diff --git a/spec/services/tournament_transaction_logger_spec.rb b/spec/services/tournament_transaction_logger_spec.rb new file mode 100644 index 0000000..78e2851 --- /dev/null +++ b/spec/services/tournament_transaction_logger_spec.rb @@ -0,0 +1,58 @@ +# frozen_string_literal: true + +require 'rails_helper' + +RSpec.describe TournamentTransactionLogger do + describe '.record!' do + it 'assigns per-tournament sequences in order' do + tournament = create(:tournament) + user = tournament.owner + + first = described_class.record!( + tournament: tournament, + user: user, + action: 'test.first', + message: 'first', + metadata: { number: 1 } + ) + second = described_class.record!( + tournament: tournament, + user: user, + action: 'test.second', + message: 'second', + metadata: { number: 2 } + ) + + expect(first.sequence).to eq(1) + expect(second.sequence).to eq(2) + expect(tournament.transaction_log_entries.order(:sequence).pluck(:message)).to eq(%w[first second]) + end + + it 'does not clear pending after_commit change tracking on the caller tournament' do + tournament = create(:tournament) + user = tournament.owner + old_timer = { timestamp: nil, timer_mode: nil, timer_reason: nil, timer_reason_text: nil } + + Tournament.transaction do + tournament.update!( + timestamp: 2.minutes.from_now, + timer_mode: 'countdown', + timer_reason: 'tournament_start' + ) + new_timer = { timestamp: tournament.timestamp.iso8601, timer_mode: 'countdown', + timer_reason: 'tournament_start', timer_reason_text: nil } + + expect(tournament.saved_change_to_timestamp?).to be(true) + + described_class.record_timer_changed!( + tournament: tournament, + user: user, + old_timer: old_timer, + new_timer: new_timer + ) + + expect(tournament.saved_change_to_timestamp?).to be(true) + end + end + end +end diff --git a/spec/support/performance_helpers.rb b/spec/support/performance_helpers.rb index 161b81c..44b22a3 100644 --- a/spec/support/performance_helpers.rb +++ b/spec/support/performance_helpers.rb @@ -22,7 +22,7 @@ module PerformanceHelpers end duration_ms = (Process.clock_gettime(Process::CLOCK_MONOTONIC) - started_at) * 1000.0 - { result:, query_count:, duration_ms: } + { result: result, query_count: query_count, duration_ms: duration_ms } end def create_group_stage_tournament(groups_count:, teams_per_group:) diff --git a/spec/support/profile.rb b/spec/support/profile.rb index c3ed499..f2a9f48 100644 --- a/spec/support/profile.rb +++ b/spec/support/profile.rb @@ -1,5 +1,3 @@ -# frozen_string_literal: true - require 'ruby-prof' RSpec.configure do |config| @@ -12,4 +10,4 @@ RSpec.configure do |config| printer = RubyProf::FlatPrinter.new(result) printer.print(File.open("profile_#{example.full_description.parameterize}.txt", 'w+')) end -end +end \ No newline at end of file diff --git a/tasks.py b/tasks.py index 64ebd2b..e0b3dec 100644 --- a/tasks.py +++ b/tasks.py @@ -28,6 +28,14 @@ E2E_USERNAME = "e2e-user" E2E_ALT_EMAIL = "e2e-alt@example.com" E2E_ALT_PASSWORD = "password123" E2E_ALT_USERNAME = "e2e-alt-user" +LOAD_HTTP_CLIENTS = "1,10,50,100" +LOAD_WEBSOCKET_CLIENTS = "1,10,50,100,200" +LOAD_HTTP_REQUESTS_PER_CLIENT = "3" +LOAD_WEBSOCKET_HOLD_SECONDS = "1.0" +LOAD_WEBSOCKET_CONNECT_BATCH_SIZE = "25" +LOAD_GROUP_COUNT = "4" +LOAD_TEAMS_PER_GROUP = "4" +LOAD_PLAYOFF_TEAMS = "8" PRODUCTION_TAG = "registry.gitlab.com/turniere/turniere-backend/production/commits:local" TEST_TAG = "registry.gitlab.com/turniere/turniere-backend/test/commits:local" @@ -49,6 +57,18 @@ BLACKBOX_MAILGUN_API_KEY = "blackbox-test-api-key" BLACKBOX_MAILGUN_DOMAIN = "blackbox.example.com" SPEC_ROOT = Path("spec") E2E_SPEC_ROOT = SPEC_ROOT / "e2e" / "http" +LINT_TARGETS = ( + "Gemfile", + "Rakefile", + "config.ru", + "app", + "config", + "db/migrate", + "db/seeds.rb", + "e2e", + "lib", + "script", +) ROOT = Path(__file__).resolve().parent DB_DIR = ROOT / "db" GEMFILE_LOCK_PATH = ROOT / "Gemfile.lock" @@ -189,6 +209,7 @@ def _compose_env( TURNIERE_BLACKBOX_SECRET_KEY_BASE=BLACKBOX_SECRET_KEY_BASE, TURNIERE_BLACKBOX_MAILGUN_API_KEY=BLACKBOX_MAILGUN_API_KEY, TURNIERE_BLACKBOX_MAILGUN_DOMAIN=BLACKBOX_MAILGUN_DOMAIN, + TURNIERE_BLACKBOX_DISABLE_EMAIL_DELIVERY="1", TURNIERE_E2E_BASE_URL=BLACKBOX_INTERNAL_BASE_URL, TURNIERE_E2E_EXPECT_PROFILING=expect_profiling, TURNIERE_E2E_EMAIL=E2E_EMAIL, @@ -220,6 +241,7 @@ def _follow_compose_env( TURNIERE_BLACKBOX_SECRET_KEY_BASE=BLACKBOX_SECRET_KEY_BASE, TURNIERE_BLACKBOX_MAILGUN_API_KEY=BLACKBOX_MAILGUN_API_KEY, TURNIERE_BLACKBOX_MAILGUN_DOMAIN=BLACKBOX_MAILGUN_DOMAIN, + TURNIERE_BLACKBOX_DISABLE_EMAIL_DELIVERY="1", TURNIERE_SOURCE_E2E_BASE_URL=BLACKBOX_SOURCE_INTERNAL_BASE_URL, TURNIERE_FOLLOWER_E2E_BASE_URL=BLACKBOX_FOLLOWER_INTERNAL_BASE_URL, TURNIERE_E2E_EMAIL=E2E_EMAIL, @@ -254,7 +276,7 @@ def _docker_build_command(tag, target, platforms=None, push=False): "--target", target, "--build-arg", - f"GIT_COMMIT_SHA={_git_commit_sha()}", + f"base_commit={_git_commit_sha()}", "-t", tag, "-f", @@ -272,7 +294,7 @@ def _docker_build_command(tag, target, platforms=None, push=False): "--target", target, "--build-arg", - f"GIT_COMMIT_SHA={_git_commit_sha()}", + f"base_commit={_git_commit_sha()}", "-t", tag, "-f", @@ -460,33 +482,53 @@ def _spec_shards(node_total): def _shared_production_env(): - return _env( - RAILS_ENV="production", - POSTGRES_URL=os.environ.get( + database_url = os.environ.get( + "DATABASE_URL", + os.environ.get( "POSTGRES_URL", f"postgres://{BLACKBOX_DB_USER}:{BLACKBOX_DB_PASSWORD}@postgres:5432/{BLACKBOX_DB_NAME}", ), + ) + rails_max_threads = os.environ.get("RAILS_MAX_THREADS", "5") + + return _env( + RAILS_ENV="production", + DATABASE_URL=database_url, + POSTGRES_URL=database_url, POSTGRES_USERNAME=os.environ.get("POSTGRES_USERNAME", BLACKBOX_DB_USER), POSTGRES_PASSWORD=os.environ.get("POSTGRES_PASSWORD", BLACKBOX_DB_PASSWORD), SECRET_KEY_BASE=os.environ.get("SECRET_KEY_BASE", BLACKBOX_SECRET_KEY_BASE), MAILGUN_API_KEY=os.environ.get("MAILGUN_API_KEY", BLACKBOX_MAILGUN_API_KEY), MAILGUN_DOMAIN=os.environ.get("MAILGUN_DOMAIN", BLACKBOX_MAILGUN_DOMAIN), + TURNIERE_DISABLE_EMAIL_DELIVERY=os.environ.get("TURNIERE_DISABLE_EMAIL_DELIVERY", "1"), RAILS_LOG_TO_STDOUT=os.environ.get("RAILS_LOG_TO_STDOUT", "1"), RAILS_SERVE_STATIC_FILES=os.environ.get("RAILS_SERVE_STATIC_FILES", "1"), + WEB_CONCURRENCY=os.environ.get("WEB_CONCURRENCY", "2"), + RAILS_MAX_THREADS=rails_max_threads, + RAILS_DB_POOL=os.environ.get("RAILS_DB_POOL", rails_max_threads), + SIDEKIQ_CONCURRENCY=os.environ.get("SIDEKIQ_CONCURRENCY", "5"), ) def _production_env_for(db_host, db_name): + database_url = f"postgres://{BLACKBOX_DB_USER}:{BLACKBOX_DB_PASSWORD}@{db_host}:5432/{db_name}" + return _env( RAILS_ENV="production", - POSTGRES_URL=f"postgres://{BLACKBOX_DB_USER}:{BLACKBOX_DB_PASSWORD}@{db_host}:5432/{db_name}", + DATABASE_URL=database_url, + POSTGRES_URL=database_url, POSTGRES_USERNAME=BLACKBOX_DB_USER, POSTGRES_PASSWORD=BLACKBOX_DB_PASSWORD, SECRET_KEY_BASE=BLACKBOX_SECRET_KEY_BASE, MAILGUN_API_KEY=BLACKBOX_MAILGUN_API_KEY, MAILGUN_DOMAIN=BLACKBOX_MAILGUN_DOMAIN, + TURNIERE_DISABLE_EMAIL_DELIVERY="1", RAILS_LOG_TO_STDOUT="1", RAILS_SERVE_STATIC_FILES="1", + WEB_CONCURRENCY=os.environ.get("WEB_CONCURRENCY", "2"), + RAILS_MAX_THREADS=os.environ.get("RAILS_MAX_THREADS", "5"), + RAILS_DB_POOL=os.environ.get("RAILS_DB_POOL", os.environ.get("RAILS_MAX_THREADS", "5")), + SIDEKIQ_CONCURRENCY=os.environ.get("SIDEKIQ_CONCURRENCY", "5"), ) @@ -522,10 +564,79 @@ def _run_blackbox_rspec(base_url, email, password, username, alt_email=E2E_ALT_E ) +def _load_env_value(value, env_name, default): + value = str(value or "").strip() + if value: + return value + return os.environ.get(env_name, default) + + +def _run_load_test( + c, + base_url=TEST_BASE_URL, + email=E2E_EMAIL, + password=E2E_PASSWORD, + username=E2E_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="", + format="text", +): + env = _env( + TURNIERE_E2E_BASE_URL=base_url, + TURNIERE_E2E_EMAIL=email, + TURNIERE_E2E_PASSWORD=password, + TURNIERE_E2E_USERNAME=username, + TURNIERE_LOAD_HTTP_CLIENTS=_load_env_value(http_clients, "TURNIERE_LOAD_HTTP_CLIENTS", LOAD_HTTP_CLIENTS), + TURNIERE_LOAD_WEBSOCKET_CLIENTS=_load_env_value( + websocket_clients, + "TURNIERE_LOAD_WEBSOCKET_CLIENTS", + LOAD_WEBSOCKET_CLIENTS, + ), + TURNIERE_LOAD_HTTP_REQUESTS_PER_CLIENT=_load_env_value( + http_requests_per_client, + "TURNIERE_LOAD_HTTP_REQUESTS_PER_CLIENT", + LOAD_HTTP_REQUESTS_PER_CLIENT, + ), + TURNIERE_LOAD_WEBSOCKET_HOLD_SECONDS=_load_env_value( + websocket_hold_seconds, + "TURNIERE_LOAD_WEBSOCKET_HOLD_SECONDS", + LOAD_WEBSOCKET_HOLD_SECONDS, + ), + TURNIERE_LOAD_WEBSOCKET_CONNECT_BATCH_SIZE=_load_env_value( + websocket_connect_batch_size, + "TURNIERE_LOAD_WEBSOCKET_CONNECT_BATCH_SIZE", + LOAD_WEBSOCKET_CONNECT_BATCH_SIZE, + ), + TURNIERE_LOAD_GROUP_COUNT=_load_env_value(group_count, "TURNIERE_LOAD_GROUP_COUNT", LOAD_GROUP_COUNT), + TURNIERE_LOAD_TEAMS_PER_GROUP=_load_env_value( + teams_per_group, + "TURNIERE_LOAD_TEAMS_PER_GROUP", + LOAD_TEAMS_PER_GROUP, + ), + TURNIERE_LOAD_PLAYOFF_TEAMS=_load_env_value( + playoff_teams_amount, + "TURNIERE_LOAD_PLAYOFF_TEAMS", + LOAD_PLAYOFF_TEAMS, + ), + TURNIERE_LOAD_FORMAT=format, + ) + if tournament_id: + env["TURNIERE_LOAD_TOURNAMENT_ID"] = str(tournament_id) + + c.run(_bundle_exec("ruby", "e2e/load_test.rb"), env=env, pty=True) + + def _start_server(command, env, log_path="/tmp/turniere-e2e-server.log"): log_handle = open(log_path, "w") process = subprocess.Popen( - ["/bin/sh", "-lc", command], + ["/bin/sh", "-c", command], env=env, stdout=log_handle, stderr=subprocess.STDOUT, @@ -534,10 +645,17 @@ def _start_server(command, env, log_path="/tmp/turniere-e2e-server.log"): return process, log_handle +def _server_env(env=None): + server_env = _env(GIT_COMMIT_SHA=_git_commit_sha()) + if _rails_env(env) == "test": + server_env["WEB_CONCURRENCY"] = os.environ.get("WEB_CONCURRENCY", "1") + return server_env + + def _start_test_server(host=SERVER_HOST, port=SERVER_PORT): return _start_server( f"{_bundle_exec('rails', 's')} -e test -b {host} -p {port}", - _env(), + _server_env("test"), ) @@ -603,7 +721,11 @@ def db_reset(c, env="test"): @task def server(c, host=SERVER_HOST, port=SERVER_PORT, env=None): """Start the backend locally in development.""" - c.run(f"RAILS_ENV={_rails_env(env)} {_bundle_exec('rails', 's')} -b {host} -p {port}", pty=True) + c.run( + f"RAILS_ENV={_rails_env(env)} {_bundle_exec('rails', 's')} -b {host} -p {port}", + env=_server_env(env), + pty=True, + ) @task(name="start-local") @@ -648,9 +770,10 @@ def start_test_local( @task -def test(c): - """Run the normal RSpec suite.""" - _stream_subprocess(_rspec_command(), env=_env()) +def test(c, spec=""): + """Run the normal RSpec suite, or one or more spec files via --spec.""" + spec_paths = shlex.split(spec) if spec else [] + _stream_subprocess(_rspec_command(*spec_paths), env=_env()) @task(name="test-shard") @@ -691,7 +814,7 @@ def test_shard(c, node_index=1, node_total=8, dry_run=False): @task def lint(c): """Run RuboCop.""" - c.run(_bundle_exec("rubocop", "--cache", "false"), pty=True) + c.run(_bundle_exec("rubocop", *LINT_TARGETS, "--cache", "false"), pty=True) @task(name="bootstrap-dev-user") @@ -800,6 +923,92 @@ def verify_http( _stop_process(process, log_handle) +@task(name="load-test") +def load_test( + c, + base_url=TEST_BASE_URL, + email=E2E_EMAIL, + password=E2E_PASSWORD, + username=E2E_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="", + format="text", +): + """Run HTTP read and ActionCable websocket load smoke tests against a running backend.""" + _run_load_test( + c, + base_url=base_url, + email=email, + password=password, + username=username, + tournament_id=tournament_id, + http_clients=http_clients, + websocket_clients=websocket_clients, + http_requests_per_client=http_requests_per_client, + websocket_hold_seconds=websocket_hold_seconds, + websocket_connect_batch_size=websocket_connect_batch_size, + group_count=group_count, + teams_per_group=teams_per_group, + playoff_teams_amount=playoff_teams_amount, + format=format, + ) + + +@task(name="verify-load-test") +def verify_load_test( + c, + base_url=TEST_BASE_URL, + email=E2E_EMAIL, + password=E2E_PASSWORD, + username=E2E_USERNAME, + reset_db=True, + http_clients="", + websocket_clients="", + http_requests_per_client="", + websocket_hold_seconds="", + websocket_connect_batch_size="", + group_count="", + teams_per_group="", + playoff_teams_amount="", +): + """Boot a local test server, then run HTTP/websocket load smoke tests against it.""" + if reset_db: + db_reset(c, env="test") + db_migrate(c, env="test") + bootstrap_e2e_user(c, email=email, password=password, username=username) + parsed_base_url = urlparse(base_url) + server_host = parsed_base_url.hostname or "127.0.0.1" + server_port = parsed_base_url.port or 3000 + + process, log_handle = _start_test_server(host=server_host, port=server_port) + try: + _wait_for_http(base_url=base_url, timeout=60) + load_test( + c, + base_url=base_url, + email=email, + password=password, + username=username, + http_clients=http_clients, + websocket_clients=websocket_clients, + http_requests_per_client=http_requests_per_client, + websocket_hold_seconds=websocket_hold_seconds, + websocket_connect_batch_size=websocket_connect_batch_size, + group_count=group_count, + teams_per_group=teams_per_group, + playoff_teams_amount=playoff_teams_amount, + ) + finally: + _stop_process(process, log_handle) + + @task def verify(c): """Run lint, normal specs, and HTTP E2E locally.""" @@ -852,6 +1061,28 @@ def scenario_intermediate( ) +@task(name="scenario-playoff-recreation") +def scenario_playoff_recreation( + c, + base_url=TEST_BASE_URL, + email=E2E_EMAIL, + password=E2E_PASSWORD, + username=E2E_USERNAME, + stop_at="third_playoffs_created", +): + """Materialize a group-stage playoff delete/recreate scenario over HTTP.""" + c.run( + f"ruby script/e2e_scenarios.rb group_stage_playoff_recreation --stop-at {stop_at}", + env=_env( + TURNIERE_E2E_BASE_URL=base_url, + TURNIERE_E2E_EMAIL=email, + TURNIERE_E2E_PASSWORD=password, + TURNIERE_E2E_USERNAME=username, + ), + pty=True, + ) + + @task(name="scenario-playoff") def scenario_playoff( c, @@ -923,6 +1154,101 @@ def docker_build_all(c): docker_build_test(c) +def _assert_output_match(label, output, pattern): + if re.search(pattern, output) is not None: + return + + tail = output[-4000:] if output else "" + raise RuntimeError(f"{label} did not match {pattern!r}.\nLast output:\n{tail}") + + +def _blackbox_app_exec(env, *command): + result = _compose_run(["exec", "-T", "app", *command], env=env, capture_output=True) + output = "\n".join(part for part in (result.stdout, result.stderr) if part) + return output.strip() + + +def _assert_blackbox_scaling_config(env, expected): + logs = _compose_run(["logs", "app"], env=env, capture_output=True).stdout + + _assert_output_match("Puma worker count", logs, rf"Workers:\s+{re.escape(expected['web_concurrency'])}\b") + _assert_output_match("Puma max threads", logs, rf"Max threads:\s+{re.escape(expected['rails_max_threads'])}\b") + _assert_output_match("Puma environment", logs, r"Environment:\s+production\b") + + pool_size = _blackbox_app_exec(env, "bundle", "exec", "rails", "runner", "puts ActiveRecord::Base.connection_pool.size") + if pool_size.splitlines()[-1] != expected["rails_db_pool"]: + raise RuntimeError(f"Expected DB pool {expected['rails_db_pool']}, got {pool_size!r}") + + sidekiq_config = _blackbox_app_exec( + env, + "ruby", + "-rerb", + "-e", + "puts ERB.new(File.read('config/sidekiq.yml')).result", + ) + expected_sidekiq = f":concurrency: {expected['sidekiq_concurrency']}" + if expected_sidekiq not in sidekiq_config: + raise RuntimeError(f"Expected Sidekiq config {expected_sidekiq!r}, got {sidekiq_config!r}") + + +@task(name="docker-blackbox-scaling-config") +def docker_blackbox_scaling_config( + c, + host_port="3004", + app_image=PRODUCTION_TAG, + runner_image=TEST_TAG, + postgres_image=BLACKBOX_POSTGRES_IMAGE, + build=True, +): + """Build once, boot the production image with two scaling env sets, and assert runtime config.""" + if build: + docker_build_production(c, tag=app_image) + + cases = [ + { + "label": "medium", + "web_concurrency": "2", + "rails_max_threads": "3", + "rails_db_pool": "4", + "sidekiq_concurrency": "6", + }, + { + "label": "event", + "web_concurrency": "3", + "rails_max_threads": "4", + "rails_db_pool": "5", + "sidekiq_concurrency": "7", + }, + ] + + for expected in cases: + env = _compose_env( + host_port=host_port, + app_image=app_image, + runner_image=runner_image, + postgres_image=postgres_image, + expect_profiling="false", + ) + env.update( + TURNIERE_BLACKBOX_RAILS_ENV="production", + TURNIERE_BLACKBOX_WEB_CONCURRENCY=expected["web_concurrency"], + TURNIERE_BLACKBOX_RAILS_MAX_THREADS=expected["rails_max_threads"], + TURNIERE_BLACKBOX_RAILS_DB_POOL=expected["rails_db_pool"], + TURNIERE_BLACKBOX_SIDEKIQ_CONCURRENCY=expected["sidekiq_concurrency"], + ) + + _print_header(f"Scaling config case: {expected['label']}") + try: + _compose_run(["down", "-v", "--remove-orphans"], env=env, check=False) + _compose_run(["up", "-d", "postgres", "app"], env=env) + _wait_for_http(base_url=f"http://127.0.0.1:{host_port}", timeout=120) + _assert_blackbox_scaling_config(env, expected) + finally: + _compose_run(["down", "-v", "--remove-orphans"], env=env, check=False) + + print("Scaling config changed across boots without rebuilding the production image.") + + @task(name="docker-blackbox-up") def docker_blackbox_up( c, @@ -1736,6 +2062,59 @@ def blackbox_service( _run_blackbox_rspec(base_url, email, password, username, alt_email, alt_password, alt_username) +@task(name="blackbox-load-service") +def blackbox_load_service( + c, + base_url=BLACKBOX_INTERNAL_BASE_URL, + email=E2E_EMAIL, + password=E2E_PASSWORD, + username=E2E_USERNAME, + http_clients="", + websocket_clients="", + http_requests_per_client="", + websocket_hold_seconds="", + websocket_connect_batch_size="", + group_count="", + teams_per_group="", + playoff_teams_amount="", +): + """Run load smoke tests against an already running production app plus Postgres sidecar.""" + _print_header("Running production database migrations") + c.run("bundle exec rails db:migrate", env=_shared_production_env(), pty=True) + + _print_header(f"Waiting for app healthcheck at {base_url}") + _wait_for_http(base_url=base_url, timeout=120) + + _print_header("Bootstrapping confirmed E2E user") + _bootstrap_blackbox_users( + c, + _shared_production_env(), + email, + password, + username, + E2E_ALT_EMAIL, + E2E_ALT_PASSWORD, + E2E_ALT_USERNAME, + ) + + _print_header("Running HTTP/websocket load smoke against production image") + _run_load_test( + c, + base_url=base_url, + email=email, + password=password, + username=username, + http_clients=http_clients, + websocket_clients=websocket_clients, + http_requests_per_client=http_requests_per_client, + websocket_hold_seconds=websocket_hold_seconds, + websocket_connect_batch_size=websocket_connect_batch_size, + group_count=group_count, + teams_per_group=teams_per_group, + playoff_teams_amount=playoff_teams_amount, + ) + + @task(name="blackbox-follow-service") def blackbox_follow_service( c,