From 2bc39ad692413a6dd31715a443d42dc65d1d7e63 Mon Sep 17 00:00:00 2001 From: Malaber Date: Mon, 27 Apr 2026 11:49:14 +0200 Subject: [PATCH 01/36] Fix version reporting ACME-122 --- Dockerfile | 4 ++-- app/controllers/version_controller.rb | 8 +------- spec/e2e/http/api_surface_spec.rb | 4 +++- 3 files changed, 6 insertions(+), 10 deletions(-) diff --git a/Dockerfile b/Dockerfile index 7e7d590..b9e9252 100644 --- a/Dockerfile +++ b/Dockerfile @@ -57,7 +57,7 @@ FROM runtime-base AS production ARG GIT_COMMIT_SHA=aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa ENV RAILS_ENV=production -ENV GIT_COMMIT_SHA=${GIT_COMMIT_SHA} +ENV 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 @@ -81,7 +81,7 @@ FROM runtime-base AS test ARG GIT_COMMIT_SHA=aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa ENV RAILS_ENV=test -ENV GIT_COMMIT_SHA=${GIT_COMMIT_SHA} +ENV GIT_COMMIT_SHA=${base_commit} RUN apt-get update -qq \ && apt-get install -y --no-install-recommends \ 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/spec/e2e/http/api_surface_spec.rb b/spec/e2e/http/api_surface_spec.rb index 64c0bf0..2c1b5ed 100644 --- a/spec/e2e/http/api_surface_spec.rb +++ b/spec/e2e/http/api_surface_spec.rb @@ -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 From 9e00193ccd6f43bcd5e100b6718f1b7885135852 Mon Sep 17 00:00:00 2001 From: Malaber Date: Mon, 27 Apr 2026 12:13:55 +0200 Subject: [PATCH 02/36] Fix version reporting ACME-122 --- Dockerfile | 16 ++++++++++------ 1 file changed, 10 insertions(+), 6 deletions(-) diff --git a/Dockerfile b/Dockerfile index b9e9252..f44458b 100644 --- a/Dockerfile +++ b/Dockerfile @@ -2,6 +2,7 @@ ARG RUBY_VERSION=3.1.2 ARG BUNDLER_VERSION=2.3.13 +ARG base_commit=aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa FROM ruby:${RUBY_VERSION}-slim AS runtime-base @@ -54,13 +55,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=${base_commit} +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,6 +71,7 @@ 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 @@ -78,10 +81,10 @@ CMD ["sh", "-lc", "bundle exec rails db:migrate && bundle exec rails s -p 3000 - FROM runtime-base AS test -ARG GIT_COMMIT_SHA=aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa +ARG base_commit=aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa -ENV RAILS_ENV=test -ENV GIT_COMMIT_SHA=${base_commit} +ENV RAILS_ENV=test \ + GIT_COMMIT_SHA=${base_commit} RUN apt-get update -qq \ && apt-get install -y --no-install-recommends \ @@ -93,6 +96,7 @@ RUN apt-get update -qq \ 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 From d0d20ed292e9789c42f8fcc01ba325f855c7716c Mon Sep 17 00:00:00 2001 From: Malaber Date: Mon, 27 Apr 2026 19:58:06 +0200 Subject: [PATCH 03/36] Fix team action list not found responses --- app/controllers/application_controller.rb | 1 + spec/e2e/http/tournament_sync_test_spec.rb | 3 ++ .../requests/team_action_list_updates_spec.rb | 54 +++++++++++++++++++ 3 files changed, 58 insertions(+) create mode 100644 spec/requests/team_action_list_updates_spec.rb diff --git a/app/controllers/application_controller.rb b/app/controllers/application_controller.rb index b1bddc6..158102a 100644 --- a/app/controllers/application_controller.rb +++ b/app/controllers/application_controller.rb @@ -8,6 +8,7 @@ class ApplicationController < ActionController::API rescue_from ActionController::ParameterMissing do |e| render json: { error: e.message }, status: :bad_request end + rescue_from ActiveRecord::RecordNotFound, with: :render_not_found_error protected diff --git a/spec/e2e/http/tournament_sync_test_spec.rb b/spec/e2e/http/tournament_sync_test_spec.rb index 1d82f59..08cb789 100644 --- a/spec/e2e/http/tournament_sync_test_spec.rb +++ b/spec/e2e/http/tournament_sync_test_spec.rb @@ -103,18 +103,21 @@ 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 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 From 3008b9fd500f2160f712db77b2a5f1e3f0e3f518 Mon Sep 17 00:00:00 2001 From: Malaber Date: Tue, 28 Apr 2026 12:28:01 +0200 Subject: [PATCH 04/36] TUR-137 add timer reasons --- app/controllers/tournaments_controller.rb | 90 ++++++++++++++----- app/models/tournament.rb | 26 +++++- app/serializers/tournament_serializer.rb | 2 + app/services/tournament_snapshot_builder.rb | 2 + app/services/tournament_snapshot_importer.rb | 2 + app/services/tournament_sync_schema.rb | 2 + .../tournament_timer_broadcast_payload.rb | 4 +- ...8120000_add_timer_reason_to_tournaments.rb | 6 ++ db/schema.rb | 4 +- doc/timer_api.md | 57 ++++++++---- doc/tournament_live_updates.md | 2 + e2e/lib/scenario_runner.rb | 7 +- .../tournaments_controller_spec.rb | 70 +++++++++++++-- spec/e2e/http/api_surface_spec.rb | 36 +++++++- spec/e2e/http/tournament_follow_sync_spec.rb | 3 + ...nament_timer_follow_sync_websocket_spec.rb | 29 +++++- .../http/tournament_timer_websocket_spec.rb | 19 +++- spec/services/tournament_live_payload_spec.rb | 23 +++++ .../tournament_snapshot_importer_spec.rb | 15 ++++ tasks.py | 2 +- 20 files changed, 344 insertions(+), 57 deletions(-) create mode 100644 db/migrate/20260428120000_add_timer_reason_to_tournaments.rb diff --git a/app/controllers/tournaments_controller.rb b/app/controllers/tournaments_controller.rb index 3625ef7..bc29529 100644 --- a/app/controllers/tournaments_controller.rb +++ b/app/controllers/tournaments_controller.rb @@ -171,14 +171,18 @@ class TournamentsController < ApplicationController def timer_params { timestamp: params[:timestamp], - timer_mode: params[:timer_mode] + timer_mode: params[:timer_mode], + timer_reason: params[:timer_reason], + timer_reason_text: params[:timer_reason_text] } 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 @@ -333,40 +337,41 @@ class TournamentsController < ApplicationController timestamp = params[:timestamp] timestamp_seconds = params[:timestamp_seconds] timer_mode = params[:timer_mode] + 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 - - params[:timestamp] = nil - params[:timer_mode] = nil - return + return validate_clear_timer_params(timestamp, timestamp_seconds, timer_mode, timer_reason, timer_reason_text) end - if timer_mode.present? && !%w[countdown countup].include?(timer_mode) - return render json: { error: 'Invalid timer_mode' }, status: :unprocessable_entity + if timer_mode.present? && !Tournament::TIMER_MODES.include?(timer_mode) + return render_timer_validation_error('Invalid timer_mode') end + timer_reason_error = validate_timer_reason_params(timer_reason, timer_reason_text) + return render_timer_validation_error(timer_reason_error) if timer_reason_error + # throw error if both timestamp and timestamp_seconds are present if timestamp.present? && timestamp_seconds.present? - return render json: { error: 'Only one of timestamp or timestamp_seconds is allowed' }, status: :unprocessable_entity + return render_timer_validation_error('Only one of timestamp or timestamp_seconds is allowed') end if timer_mode == 'countup' && timestamp_seconds.present? - return render json: { error: 'timestamp_seconds is only allowed for countdown mode' }, status: :unprocessable_entity + return render_timer_validation_error('timestamp_seconds is only allowed for countdown mode') end if timestamp_seconds.present? begin timestamp_seconds = Integer(timestamp_seconds) rescue ArgumentError - return render json: { error: 'Invalid seconds format' }, status: :unprocessable_entity + return render_timer_validation_error('Invalid seconds format') end - return render json: { error: 'Timestamp must be in the future for countdown mode' }, status: :unprocessable_entity if timestamp_seconds <= 0 + if timestamp_seconds <= 0 + return render_timer_validation_error('Timestamp must be in the future for countdown mode') + end parsed_time = Time.zone.now + timestamp_seconds params[:timestamp] = parsed_time @@ -375,24 +380,63 @@ class TournamentsController < ApplicationController begin parsed_time = Time.zone.parse(timestamp) if parsed_time.nil? - return render json: { error: 'Invalid timestamp format' }, status: :unprocessable_entity + return render_timer_validation_error('Invalid timestamp format') elsif timer_mode != 'countup' && !parsed_time.future? - return render json: { error: 'Timestamp must be in the future for countdown mode' }, status: :unprocessable_entity + return render_timer_validation_error('Timestamp must be in the future for countdown mode') end params[:timestamp] = parsed_time params[:timer_mode] = timer_mode.presence || 'countdown' rescue ArgumentError - return render json: { error: 'Invalid timestamp format' }, status: :unprocessable_entity + return render_timer_validation_error('Invalid timestamp format') end elsif timer_mode == 'countup' params[:timestamp] = Time.zone.now elsif timer_mode.present? - return render json: { error: 'timer_mode requires timestamp, timestamp_seconds, or clear_timer' }, - status: :unprocessable_entity + return render_timer_validation_error('timer_mode requires timestamp, timestamp_seconds, or clear_timer') else - return render json: { error: 'timestamp, timestamp_seconds, or clear_timer is required' }, - status: :unprocessable_entity + return render_timer_validation_error('timestamp, timestamp_seconds, or clear_timer is required') end end + + def normalize_timer_reason_params + params[:timer_reason] = params[:timer_reason].presence + params[:timer_reason_text] = timer_reason_text_param + end + + def timer_reason_text_param + return nil unless params.key?(:timer_reason_text) + + params[:timer_reason_text].to_s.strip.presence + end + + def validate_clear_timer_params(timestamp, timestamp_seconds, timer_mode, timer_reason, timer_reason_text) + if timestamp.present? || timestamp_seconds.present? || timer_mode.present? || + timer_reason.present? || timer_reason_text.present? + return render_timer_validation_error('clear_timer cannot be combined with timer values or timer reason') + end + + params[:timestamp] = nil + params[:timer_mode] = nil + params[:timer_reason] = nil + params[:timer_reason_text] = nil + end + + def validate_timer_reason_params(timer_reason, timer_reason_text) + 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) + + if timer_reason == 'custom' && timer_reason_text.blank? + return 'timer_reason_text is required for custom timer_reason' + end + + if timer_reason != 'custom' && timer_reason_text.present? + 'timer_reason_text is only allowed when timer_reason is custom' + end + end + + def render_timer_validation_error(message) + render json: { error: message }, status: :unprocessable_entity + end end diff --git a/app/models/tournament.rb b/app/models/tournament.rb index 3debf27..9decf6c 100644 --- a/app/models/tournament.rb +++ b/app/models/tournament.rb @@ -3,6 +3,9 @@ require 'securerandom' class Tournament < ApplicationRecord + TIMER_MODES = %w[countdown countup].freeze + TIMER_REASONS = %w[tournament_start playoff_start match_end custom].freeze + belongs_to :user has_many :teams, dependent: :destroy has_many :stages, dependent: :destroy @@ -11,16 +14,19 @@ class Tournament < ApplicationRecord 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 validate :sync_configuration_is_complete validate :playoff_teams_amount_is_positive_power_of_two + validate :timer_reason_text_matches_reason alias_attribute :owner, :user after_initialize :generate_code after_create_commit :ensure_default_beamer! + before_validation :normalize_timer_reason before_validation :clear_follower_sync_token_when_disabling_read_only_mode after_commit :broadcast_timer_state_change, if: :saved_change_to_timer_state? @@ -100,8 +106,24 @@ class Tournament < ApplicationRecord self.sync_auth_token = nil 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 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/serializers/tournament_serializer.rb b/app/serializers/tournament_serializer.rb index 46009a1..2691690 100644 --- a/app/serializers/tournament_serializer.rb +++ b/app/serializers/tournament_serializer.rb @@ -16,6 +16,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 diff --git a/app/services/tournament_snapshot_builder.rb b/app/services/tournament_snapshot_builder.rb index 8ca4a26..0975a78 100644 --- a/app/services/tournament_snapshot_builder.rb +++ b/app/services/tournament_snapshot_builder.rb @@ -24,6 +24,8 @@ class TournamentSnapshotBuilder payload = tournament.attributes.symbolize_keys.slice(*ROOT_ATTRIBUTES) 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 af298c2..c2868da 100644 --- a/app/services/tournament_snapshot_importer.rb +++ b/app/services/tournament_snapshot_importer.rb @@ -69,6 +69,8 @@ class TournamentSnapshotImporter root_attrs[:code] = tournament.code if code_taken_by_another_tournament?(root_attrs[:code]) 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) diff --git a/app/services/tournament_sync_schema.rb b/app/services/tournament_sync_schema.rb index 64b4d33..d698bf5 100644 --- a/app/services/tournament_sync_schema.rb +++ b/app/services/tournament_sync_schema.rb @@ -14,6 +14,8 @@ class TournamentSyncSchema intermediate_round_participants_amount timestamp timer_mode + timer_reason + timer_reason_text ].freeze, ignored: %w[ user_id 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/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..54cb911 --- /dev/null +++ b/db/migrate/20260428120000_add_timer_reason_to_tournaments.rb @@ -0,0 +1,6 @@ +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/schema.rb b/db/schema.rb index 002741a..9ebcac7 100644 --- a/db/schema.rb +++ b/db/schema.rb @@ -10,7 +10,7 @@ # # 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[7.0].define(version: 2026_04_28_120000) do create_table "beamers", force: :cascade do |t| t.string "name", null: false t.string "display_state", default: "during_game", null: false @@ -167,6 +167,8 @@ ActiveRecord::Schema[7.0].define(version: 2026_04_26_121000) do t.string "sync_last_push_error" t.datetime "sync_last_imported_snapshot_at" t.string "timer_mode" + t.string "timer_reason" + t.text "timer_reason_text" t.index ["code"], name: "index_tournaments_on_code", unique: true t.index ["user_id"], name: "index_tournaments_on_user_id" end diff --git a/doc/timer_api.md b/doc/timer_api.md index dcb1169..f1d0622 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" } ``` @@ -179,10 +197,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 +214,9 @@ 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` +- `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 +246,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 +268,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 +277,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 +310,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 +327,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 @@ -321,5 +348,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/e2e/lib/scenario_runner.rb b/e2e/lib/scenario_runner.rb index eb67787..664db82 100644 --- a/e2e/lib/scenario_runner.rb +++ b/e2e/lib/scenario_runner.rb @@ -30,9 +30,12 @@ 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) } + checkpoints[:timer_set] = { + timestamp: timer_response.fetch(:timestamp), + timer_reason: timer_response.fetch(:timer_reason) + } return result(session: session, tournament: tournament, checkpoints: checkpoints, stop_at: stop_at) if stop_at == :timer_set upcoming_matches = get!( diff --git a/spec/controllers/tournaments_controller_spec.rb b/spec/controllers/tournaments_controller_spec.rb index 9a9cb9a..2f95deb 100644 --- a/spec/controllers/tournaments_controller_spec.rb +++ b/spec/controllers/tournaments_controller_spec.rb @@ -105,7 +105,7 @@ 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 + 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) @@ -224,13 +224,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: 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,35 +249,60 @@ 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: 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.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 'rejects timestamp_seconds for countup mode' do @@ -279,6 +311,34 @@ RSpec.describe TournamentsController, type: :controller do expect(response).to have_http_status(:unprocessable_entity) 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_entity) + 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_entity) + 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_entity) + expect(deserialize_response(response)[:error]).to eq( + 'timer_reason_text is only allowed when timer_reason is custom' + ) + end end describe 'POST #create' do diff --git a/spec/e2e/http/api_surface_spec.rb b/spec/e2e/http/api_surface_spec.rb index 2c1b5ed..8575991 100644 --- a/spec/e2e/http/api_surface_spec.rb +++ b/spec/e2e/http/api_surface_spec.rb @@ -134,15 +134,32 @@ 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_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', @@ -171,18 +188,31 @@ 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_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) diff --git a/spec/e2e/http/tournament_follow_sync_spec.rb b/spec/e2e/http/tournament_follow_sync_spec.rb index 1800ad6..aa3f87c 100644 --- a/spec/e2e/http/tournament_follow_sync_spec.rb +++ b/spec/e2e/http/tournament_follow_sync_spec.rb @@ -504,6 +504,9 @@ RSpec.describe 'Tournament follower sync HTTP E2E' do instant_finalists_amount: tournament.fetch(:instant_finalists_amount), intermediate_round_participants_amount: tournament.fetch(:intermediate_round_participants_amount), timestamp: tournament[:timestamp], + 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| { 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 3b8c364..4112807 100644 --- a/spec/e2e/http/tournament_timer_follow_sync_websocket_spec.rb +++ b/spec/e2e/http/tournament_timer_follow_sync_websocket_spec.rb @@ -40,24 +40,42 @@ 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' }) + 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 @@ -95,6 +113,13 @@ RSpec.describe 'Tournament timer follower sync websocket HTTP E2E' do response.fetch(:json) end + def sync_now_if_requested!(source_tournament_id) + return unless ENV.fetch('TURNIERE_E2E_SYNC_MODE', nil) == 'manual' + + 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) deadline = Time.now + timeout loop do diff --git a/spec/e2e/http/tournament_timer_websocket_spec.rb b/spec/e2e/http/tournament_timer_websocket_spec.rb index 1431476..73913bb 100644 --- a/spec/e2e/http/tournament_timer_websocket_spec.rb +++ b/spec/e2e/http/tournament_timer_websocket_spec.rb @@ -31,24 +31,39 @@ RSpec.describe 'Tournament timer 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 = 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' }) + 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 diff --git a/spec/services/tournament_live_payload_spec.rb b/spec/services/tournament_live_payload_spec.rb index cd91ab4..21cb34b 100644 --- a/spec/services/tournament_live_payload_spec.rb +++ b/spec/services/tournament_live_payload_spec.rb @@ -83,4 +83,27 @@ 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 end diff --git a/spec/services/tournament_snapshot_importer_spec.rb b/spec/services/tournament_snapshot_importer_spec.rb index 03f9b1a..14f3795 100644 --- a/spec/services/tournament_snapshot_importer_spec.rb +++ b/spec/services/tournament_snapshot_importer_spec.rb @@ -6,6 +6,12 @@ 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!( + timestamp: Time.utc(2026, 4, 24, 10, 15, 0), + timer_mode: 'countdown', + timer_reason: 'custom', + timer_reason_text: 'Opening ceremony' + ) source.beamers.create!( name: 'stream-a', display_state: 'during_game', @@ -23,6 +29,8 @@ RSpec.describe TournamentSnapshotImporter do expect(follower.sync_source_tournament_id).to eq(source.id) expect(follower.name).to eq(source.name) 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) @@ -127,6 +135,11 @@ RSpec.describe TournamentSnapshotImporter do tournament.reload play_some_playoff_matches!(tournament) + tournament.update!( + timestamp: Time.utc(2026, 4, 24, 10, 15, 0), + timer_mode: 'countdown', + timer_reason: 'playoff_start' + ) tournament.reload end @@ -186,6 +199,8 @@ RSpec.describe TournamentSnapshotImporter do intermediate_round_participants_amount: tournament.intermediate_round_participants_amount, timestamp: tournament.timestamp&.iso8601, timer_mode: tournament.timer_mode, + 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| { diff --git a/tasks.py b/tasks.py index 3bb5306..69986f7 100644 --- a/tasks.py +++ b/tasks.py @@ -525,7 +525,7 @@ def _run_blackbox_rspec(base_url, email, password, username, alt_email=E2E_ALT_E 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, From fe0f3c868ebeccaef7be5fd86fb9430946e9c0b5 Mon Sep 17 00:00:00 2001 From: Malaber Date: Tue, 28 Apr 2026 13:55:47 +0200 Subject: [PATCH 05/36] TUR-130 update dependencies and ruby --- .rubocop.yml | 14 +- .rubocop_todo.yml | 563 ++++++++++++++++++++++ .ruby-version | 2 +- Dockerfile | 18 +- Gemfile | 12 +- Gemfile.lock | 521 +++++++++++--------- app/controllers/application_controller.rb | 11 +- app/models/match.rb | 4 +- app/models/stage.rb | 2 +- app/models/tournament.rb | 3 +- config/application.rb | 2 - config/environments/production.rb | 16 +- db/schema.rb | 146 +++--- docker-compose.blackbox-follow.yml | 2 + docker-compose.blackbox.yml | 1 + spec/e2e/http/api_surface_spec.rb | 2 +- spec/rails_helper.rb | 2 +- tasks.py | 14 +- 18 files changed, 1005 insertions(+), 330 deletions(-) create mode 100644 .rubocop_todo.yml diff --git a/.rubocop.yml b/.rubocop.yml index 472e0f0..5a901d6 100644 --- a/.rubocop.yml +++ b/.rubocop.yml @@ -1,9 +1,17 @@ -require: +inherit_from: .rubocop_todo.yml + +inherit_mode: + merge: + - Exclude + +plugins: - rubocop-performance - rubocop-rails # Stolen from https://gist.github.com/jhass/a5ae80d87f18e53e7b56 AllCops: + NewCops: disable + SuggestExtensions: false Exclude: - "bin/**/*" - "db/schema.rb" @@ -13,7 +21,7 @@ Rails: Enabled: true # Commonly used screens these days easily fit more than 80 characters. -Metrics/LineLength: +Layout/LineLength: Max: 120 # Too short methods lead to extraction of single-use methods, which can make @@ -36,7 +44,7 @@ Metrics/AbcSize: Max: 50 Metrics/BlockLength: - ExcludedMethods: + AllowedMethods: - "namespace" - "create_table" Exclude: diff --git a/.rubocop_todo.yml b/.rubocop_todo.yml new file mode 100644 index 0000000..de039d0 --- /dev/null +++ b/.rubocop_todo.yml @@ -0,0 +1,563 @@ +# This configuration was generated by +# `rubocop --auto-gen-config --no-exclude-limit` +# on 2026-04-28 09:58:15 UTC using RuboCop version 1.86.1. +# The point is for the user to remove these configuration records +# one by one as the offenses are removed from the code base. +# Note that changes in the inspected code, or installation of new +# versions of RuboCop, may require this file to be generated again. + +# Offense count: 2 +# This cop supports safe autocorrection (--autocorrect). +# Configuration parameters: TreatCommentsAsGroupSeparators, ConsiderPunctuation. +Bundler/OrderedGems: + Exclude: + - 'Gemfile' + +# Offense count: 1 +# This cop supports safe autocorrection (--autocorrect). +# Configuration parameters: EnforcedStyleAlignWith. +# SupportedStylesAlignWith: either, start_of_block, start_of_line +Layout/BlockAlignment: + Exclude: + - 'spec/routing/tournaments_routing_spec.rb' + +# Offense count: 1 +# This cop supports safe autocorrection (--autocorrect). +# Configuration parameters: AllowForAlignment. +Layout/CommentIndentation: + Exclude: + - 'app/serializers/tournament_serializer.rb' + +# Offense count: 3 +# This cop supports safe autocorrection (--autocorrect). +Layout/EmptyLineAfterGuardClause: + Exclude: + - 'app/controllers/tournaments_controller.rb' + - 'config/spring.rb' + - 'spec/factories/tournaments.rb' + +# Offense count: 3 +# This cop supports safe autocorrection (--autocorrect). +# Configuration parameters: EmptyLineBetweenMethodDefs, EmptyLineBetweenClassDefs, EmptyLineBetweenModuleDefs, DefLikeMacros, AllowAdjacentOneLineDefs, NumberOfEmptyLines. +Layout/EmptyLineBetweenDefs: + Exclude: + - 'app/controllers/tournaments_controller.rb' + - 'app/services/tournament_snapshot_importer.rb' + - 'e2e/lib/scenario_runner.rb' + +# Offense count: 1 +# This cop supports safe autocorrection (--autocorrect). +Layout/EmptyLines: + Exclude: + - 'app/controllers/tournaments_controller.rb' + +# Offense count: 1 +# This cop supports safe autocorrection (--autocorrect). +# Configuration parameters: EnforcedStyle. +# SupportedStyles: around, only_before +Layout/EmptyLinesAroundAccessModifier: + Exclude: + - 'db/migrate/20220516203839_create_active_storage_variant_records.active_storage.rb' + +# Offense count: 1 +# This cop supports safe autocorrection (--autocorrect). +# Configuration parameters: EnforcedStyle. +# SupportedStyles: empty_lines, empty_lines_except_namespace, empty_lines_special, no_empty_lines, beginning_only, ending_only +Layout/EmptyLinesAroundClassBody: + Exclude: + - 'config/application.rb' + +# Offense count: 2 +# This cop supports safe autocorrection (--autocorrect). +# Configuration parameters: IndentationWidth. +# SupportedStyles: special_inside_parentheses, consistent, align_brackets +Layout/FirstArrayElementIndentation: + EnforcedStyle: consistent + +# Offense count: 6 +# This cop supports safe autocorrection (--autocorrect). +# Configuration parameters: EnforcedStyle, IndentationWidth. +# SupportedStyles: special_inside_parentheses, consistent, align_braces +Layout/FirstHashElementIndentation: + Exclude: + - 'spec/e2e/http/tournament_follow_sync_spec.rb' + +# Offense count: 5 +# This cop supports safe autocorrection (--autocorrect). +# Configuration parameters: EnforcedStyle. +# SupportedStyles: normal, indented_internal_methods +Layout/IndentationConsistency: + Exclude: + - 'db/migrate/20220516203839_create_active_storage_variant_records.active_storage.rb' + - 'e2e/lib/scenario_runner.rb' + - 'spec/routing/tournaments_routing_spec.rb' + +# Offense count: 3 +# This cop supports safe autocorrection (--autocorrect). +# Configuration parameters: Width, EnforcedStyleAlignWith, AllowedPatterns. +# SupportedStylesAlignWith: start_of_line, relative_to_receiver +Layout/IndentationWidth: + Exclude: + - 'db/migrate/20220516203839_create_active_storage_variant_records.active_storage.rb' + - 'spec/routing/tournaments_routing_spec.rb' + +# Offense count: 10 +# This cop supports safe autocorrection (--autocorrect). +# Configuration parameters: EnforcedStyle, IndentationWidth. +# SupportedStyles: aligned, indented, indented_relative_to_receiver +Layout/MultilineMethodCallIndentation: + Exclude: + - 'app/interactors/advance_teams_in_intermediate_stage.rb' + - 'e2e/lib/scenario_runner.rb' + - 'spec/e2e/http/api_surface_spec.rb' + - 'spec/e2e/http/tournament_follow_sync_live_websocket_spec.rb' + - 'spec/e2e/http/tournament_follow_sync_spec.rb' + - 'spec/e2e/http/tournament_lifecycle_spec.rb' + - 'spec/e2e/http/tournament_live_websocket_spec.rb' + +# Offense count: 3 +# This cop supports safe autocorrection (--autocorrect). +# Configuration parameters: EnforcedStyle, IndentationWidth. +# SupportedStyles: aligned, indented +Layout/MultilineOperationIndentation: + Exclude: + - 'app/services/group_stage_service.rb' + +# Offense count: 2 +# This cop supports safe autocorrection (--autocorrect). +Layout/SpaceInsidePercentLiteralDelimiters: + Exclude: + - 'db/migrate/20220516203839_create_active_storage_variant_records.active_storage.rb' + +# Offense count: 1 +# This cop supports safe autocorrection (--autocorrect). +# Configuration parameters: EnforcedStyle. +# SupportedStyles: final_newline, final_blank_line +Layout/TrailingEmptyLines: + Exclude: + - 'spec/support/profile.rb' + +# Offense count: 1 +# This cop supports unsafe autocorrection (--autocorrect-all). +# Configuration parameters: AllowSafeAssignment. +Lint/AssignmentInCondition: + Exclude: + - 'db/migrate/20220516203838_add_service_name_to_active_storage_blobs.active_storage.rb' + +# Offense count: 2 +# This cop supports safe autocorrection (--autocorrect). +Lint/ScriptPermission: + Exclude: + - 'script/e2e_scenarios.rb' + - 'script/wait_for_http.rb' + +# Offense count: 1 +# This cop supports safe autocorrection (--autocorrect). +# Configuration parameters: AllowUnusedKeywordArguments, IgnoreEmptyMethods, IgnoreNotImplementedMethods, NotImplementedExceptions. +# NotImplementedExceptions: NotImplementedError +Lint/UnusedMethodArgument: + Exclude: + - 'app/controllers/application_controller.rb' + +# Offense count: 2 +# This cop supports safe autocorrection (--autocorrect). +Lint/UselessAssignment: + Exclude: + - 'e2e/lib/scenario_runner.rb' + - 'spec/models/beamer_spec.rb' + +# Offense count: 6 +# Configuration parameters: AllowedMethods, AllowedPatterns, CountRepeatedAttributes, Max. +Metrics/AbcSize: + Exclude: + - 'app/controllers/tournaments_controller.rb' + - 'app/services/tournament_snapshot_importer.rb' + - 'e2e/lib/scenario_runner.rb' + - 'spec/e2e/http/tournament_follow_sync_spec.rb' + - 'spec/services/tournament_snapshot_importer_spec.rb' + +# Offense count: 2 +# Configuration parameters: CountComments, Max, CountAsOne, AllowedMethods, AllowedPatterns. +# AllowedMethods: refine +Metrics/BlockLength: + Exclude: + - 'app/services/tournament_snapshot_importer.rb' + - 'db/schema.rb' + +# Offense count: 15 +# Configuration parameters: AllowedMethods, AllowedPatterns, Max. +Metrics/CyclomaticComplexity: + Exclude: + - 'app/controllers/matches_controller.rb' + - 'app/controllers/tournaments_controller.rb' + - 'app/models/group_score.rb' + - 'app/models/stage.rb' + - 'app/services/group_stage_service.rb' + - 'app/services/playoff_stage_service.rb' + - 'e2e/lib/api_client.rb' + - 'spec/e2e/http/tournament_follow_sync_spec.rb' + - 'spec/services/tournament_snapshot_importer_spec.rb' + +# Offense count: 1 +# Configuration parameters: CountComments, Max, CountAsOne, AllowedMethods, AllowedPatterns. +Metrics/MethodLength: + Exclude: + - 'app/controllers/tournaments_controller.rb' + +# Offense count: 1 +# Configuration parameters: Max, CountKeywordArgs, MaxOptionalParameters. +Metrics/ParameterLists: + Exclude: + - 'e2e/lib/api_client.rb' + +# Offense count: 14 +# Configuration parameters: AllowedMethods, AllowedPatterns, Max. +Metrics/PerceivedComplexity: + Exclude: + - 'app/controllers/matches_controller.rb' + - 'app/controllers/tournaments_controller.rb' + - 'app/models/group_score.rb' + - 'app/models/stage.rb' + - 'app/services/group_stage_service.rb' + - 'e2e/lib/api_client.rb' + - 'spec/e2e/http/tournament_follow_sync_spec.rb' + - 'spec/services/tournament_snapshot_importer_spec.rb' + +# Offense count: 3 +# This cop supports unsafe autocorrection (--autocorrect-all). +Performance/Count: + Exclude: + - 'app/controllers/tournaments_controller.rb' + - 'spec/services/tournament_snapshot_importer_spec.rb' + +# Offense count: 2 +# This cop supports unsafe autocorrection (--autocorrect-all). +# Configuration parameters: SafeMultiline. +Performance/DeleteSuffix: + Exclude: + - 'e2e/lib/action_cable_client.rb' + - 'e2e/lib/api_client.rb' + +# Offense count: 1 +# This cop supports safe autocorrection (--autocorrect). +Performance/RegexpMatch: + Exclude: + - 'config/spring.rb' + +# Offense count: 14 +# This cop supports unsafe autocorrection (--autocorrect-all). +Performance/TimesMap: + Exclude: + - 'e2e/lib/scenario_runner.rb' + - 'spec/e2e/http/api_surface_spec.rb' + - 'spec/e2e/http/tournament_beamers_websocket_spec.rb' + - 'spec/e2e/http/tournament_follow_sync_beamers_websocket_spec.rb' + - 'spec/e2e/http/tournament_follow_sync_spec.rb' + - 'spec/e2e/http/tournament_follow_sync_websocket_spec.rb' + - 'spec/e2e/http/tournament_sync_test_spec.rb' + - 'spec/e2e/http/tournament_team_action_lists_websocket_spec.rb' + - 'spec/e2e/http/tournament_timer_follow_sync_websocket_spec.rb' + - 'spec/e2e/http/tournament_timer_websocket_spec.rb' + - 'spec/services/tournament_snapshot_importer_spec.rb' + +# Offense count: 3 +# This cop supports unsafe autocorrection (--autocorrect-all). +# Configuration parameters: NilOrEmpty, NotPresent, UnlessPresent. +Rails/Blank: + Exclude: + - 'e2e/lib/scenario_runner.rb' + - 'spec/e2e/http/tournament_sync_test_spec.rb' + +# Offense count: 1 +# This cop supports safe autocorrection (--autocorrect). +# Configuration parameters: EnforceForPrefixed. +Rails/Delegate: + Exclude: + - 'e2e/lib/action_cable_client.rb' + +# Offense count: 2 +# This cop supports safe autocorrection (--autocorrect). +# Configuration parameters: EnforcedStyle. +# SupportedStyles: slashes, arguments +Rails/FilePath: + Exclude: + - 'config/environments/development.rb' + - 'spec/rails_helper.rb' + +# Offense count: 1 +# This cop supports safe autocorrection (--autocorrect). +Rails/IndexBy: + Exclude: + - 'spec/e2e/http/tournament_follow_sync_spec.rb' + +# Offense count: 1 +# Configuration parameters: IgnoreScopes. +Rails/InverseOf: + Exclude: + - 'app/models/tournament.rb' + +# Offense count: 1 +# This cop supports safe autocorrection (--autocorrect). +# Configuration parameters: NotNilAndNotEmpty, NotBlank, UnlessBlank. +Rails/Present: + Exclude: + - 'e2e/lib/api_client.rb' + +# Offense count: 1 +Rails/ReversibleMigration: + Exclude: + - 'db/migrate/20240407180900_add_default_to_position_in_group_scores.rb' + +# Offense count: 9 +# Configuration parameters: ForbiddenMethods, AllowedMethods. +# ForbiddenMethods: decrement!, decrement_counter, increment!, increment_counter, insert, insert!, insert_all, insert_all!, toggle!, touch, touch_all, update_all, update_attribute, update_column, update_columns, update_counters, upsert, upsert_all +Rails/SkipsModelValidations: + Exclude: + - 'app/models/tournament_sync_queue_entry.rb' + - 'app/services/tournament_sync_pusher.rb' + - 'db/migrate/20220516203838_add_service_name_to_active_storage_blobs.active_storage.rb' + - 'db/migrate/20260416102000_backfill_default_beamers_by_name.rb' + - 'spec/controllers/tournaments_controller_spec.rb' + +# Offense count: 33 +# This cop supports unsafe autocorrection (--autocorrect-all). +# Configuration parameters: EnforcedStyle. +# SupportedStyles: strict, flexible +Rails/TimeZone: + Exclude: + - 'e2e/lib/action_cable_client.rb' + - 'e2e/lib/scenario_runner.rb' + - 'spec/e2e/http/tournament_beamers_websocket_spec.rb' + - 'spec/e2e/http/tournament_follow_sync_beamers_websocket_spec.rb' + - 'spec/e2e/http/tournament_follow_sync_spec.rb' + - 'spec/e2e/http/tournament_follow_sync_websocket_spec.rb' + - 'spec/e2e/http/tournament_team_action_lists_websocket_spec.rb' + - 'spec/e2e/http/tournament_timer_follow_sync_websocket_spec.rb' + - 'spec/e2e/http/tournament_timer_websocket_spec.rb' + +# Offense count: 2 +# This cop supports safe autocorrection (--autocorrect). +# Configuration parameters: EnforcedStyle, ProceduralMethods, FunctionalMethods, AllowedMethods, AllowedPatterns, AllowBracesOnProceduralOneLiners, BracesRequiredMethods. +# SupportedStyles: line_count_based, semantic, braces_for_chaining, always_braces +# ProceduralMethods: benchmark, bm, bmbm, create, each_with_object, measure, new, realtime, tap, with_object +# FunctionalMethods: let, let!, subject, watch +# AllowedMethods: lambda, proc, it +Style/BlockDelimiters: + Exclude: + - 'spec/controllers/beamers_controller_spec.rb' + +# Offense count: 2 +# This cop supports safe autocorrection (--autocorrect). +# Configuration parameters: Keywords, RequireColon. +# Keywords: TODO, FIXME, OPTIMIZE, HACK, REVIEW, NOTE +Style/CommentAnnotation: + Exclude: + - 'spec/controllers/matches_controller_spec.rb' + - 'spec/services/group_stage_service_spec.rb' + +# Offense count: 1 +# This cop supports safe autocorrection (--autocorrect). +# Configuration parameters: EnforcedStyle, SingleLineConditionsOnly, IncludeTernaryExpressions. +# SupportedStyles: assign_to_condition, assign_inside_condition +Style/ConditionalAssignment: + Exclude: + - 'spec/factories/tournaments.rb' + +# Offense count: 12 +# This cop supports unsafe autocorrection (--autocorrect-all). +# Configuration parameters: EnforcedStyle. +# SupportedStyles: always, always_true, never +Style/FrozenStringLiteralComment: + Exclude: + - 'config/boot.rb' + - 'config/initializers/filter_parameter_logging.rb' + - 'config/initializers/new_framework_defaults_7_0.rb' + - 'db/migrate/20220516203838_add_service_name_to_active_storage_blobs.active_storage.rb' + - 'db/migrate/20220516203839_create_active_storage_variant_records.active_storage.rb' + - 'db/migrate/20220516203840_remove_not_null_on_active_storage_blobs_checksum.active_storage.rb' + - 'db/migrate/20240407175005_add_position_to_group_scores.rb' + - 'db/migrate/20240407180900_add_default_to_position_in_group_scores.rb' + - 'db/migrate/20250309202658_add_timer_end_to_tournaments.rb' + - 'db/migrate/20260423133000_add_timer_mode_to_tournaments.rb' + - 'db/schema.rb' + - 'spec/support/profile.rb' + +# Offense count: 1 +# This cop supports unsafe autocorrection (--autocorrect-all). +Style/GlobalStdStream: + Exclude: + - 'config/environments/production.rb' + +# Offense count: 1 +# This cop supports safe autocorrection (--autocorrect). +# Configuration parameters: MinBodyLength, AllowConsecutiveConditionals. +Style/GuardClause: + Exclude: + - 'db/migrate/20220516203838_add_service_name_to_active_storage_blobs.active_storage.rb' + +# Offense count: 2 +# This cop supports unsafe autocorrection (--autocorrect-all). +Style/IdenticalConditionalBranches: + Exclude: + - 'spec/e2e/http/tournament_lifecycle_spec.rb' + +# Offense count: 18 +# This cop supports safe autocorrection (--autocorrect). +Style/IfUnlessModifier: + Exclude: + - 'app/controllers/tournaments_controller.rb' + - 'app/services/group_stage_service.rb' + - 'app/services/playoff_stage_service.rb' + - 'e2e/lib/scenario_runner.rb' + +# Offense count: 2 +# This cop supports unsafe autocorrection (--autocorrect-all). +# Configuration parameters: InverseMethods, InverseBlocks. +Style/InverseMethods: + Exclude: + - 'spec/e2e/http/tournament_lifecycle_spec.rb' + +# Offense count: 3 +Style/MultilineBlockChain: + Exclude: + - 'e2e/lib/api_client.rb' + - 'spec/e2e/http/tournament_follow_sync_spec.rb' + +# Offense count: 1 +# This cop supports safe autocorrection (--autocorrect). +# Configuration parameters: MinDigits, Strict, AllowedNumbers, AllowedPatterns. +Style/NumericLiterals: + Exclude: + - 'db/schema.rb' + +# Offense count: 2 +# This cop supports unsafe autocorrection (--autocorrect-all). +# Configuration parameters: EnforcedStyle, AllowedMethods, AllowedPatterns. +# SupportedStyles: predicate, comparison +Style/NumericPredicate: + Exclude: + - 'e2e/lib/scenario_runner.rb' + +# Offense count: 1 +# Configuration parameters: AllowedMethods. +# AllowedMethods: respond_to_missing? +Style/OptionalBooleanParameter: + Exclude: + - 'app/services/playoff_stage_service.rb' + +# Offense count: 1 +# This cop supports safe autocorrection (--autocorrect). +Style/RedundantFreeze: + Exclude: + - 'e2e/lib/scenario_runner.rb' + +# Offense count: 3 +# This cop supports safe autocorrection (--autocorrect). +# Configuration parameters: AllowMultipleReturnValues. +Style/RedundantReturn: + Exclude: + - 'app/controllers/tournaments_controller.rb' + +# Offense count: 3 +# This cop supports unsafe autocorrection (--autocorrect-all). +Style/RedundantSort: + Exclude: + - 'spec/e2e/http/tournament_follow_sync_spec.rb' + +# Offense count: 1 +# This cop supports unsafe autocorrection (--autocorrect-all). +# Configuration parameters: ConvertCodeThatCanStartToReturnNil, AllowedMethods, MaxChainLength. +# AllowedMethods: present?, blank?, presence, try, try! +Style/SafeNavigation: + Exclude: + - 'config/spring.rb' + +# Offense count: 1 +# This cop supports unsafe autocorrection (--autocorrect-all). +# Configuration parameters: Mode. +Style/StringConcatenation: + Exclude: + - 'config/spring.rb' + +# Offense count: 246 +# This cop supports safe autocorrection (--autocorrect). +# Configuration parameters: EnforcedStyle, ConsistentQuotesInMultiline. +# SupportedStyles: single_quotes, double_quotes +Style/StringLiterals: + Exclude: + - 'config/boot.rb' + - 'db/migrate/20220516203839_create_active_storage_variant_records.active_storage.rb' + - 'db/schema.rb' + - 'spec/e2e/http/api_surface_spec.rb' + +# Offense count: 3 +# This cop supports safe autocorrection (--autocorrect). +# Configuration parameters: . +# SupportedStyles: percent, brackets +Style/SymbolArray: + EnforcedStyle: percent + MinSize: 10 + +# Offense count: 1 +# This cop supports unsafe autocorrection (--autocorrect-all). +# Configuration parameters: AllowMethodsWithArguments, AllowedMethods, AllowedPatterns, AllowComments. +# AllowedMethods: define_method, mail, respond_to +Style/SymbolProc: + Exclude: + - 'spec/services/tournament_snapshot_importer_spec.rb' + +# Offense count: 1 +# This cop supports safe autocorrection (--autocorrect). +# Configuration parameters: EnforcedStyleForMultiline. +# SupportedStylesForMultiline: comma, consistent_comma, diff_comma, no_comma +Style/TrailingCommaInArrayLiteral: + Exclude: + - 'spec/services/playoff_stage_service_spec.rb' + +# Offense count: 3 +# This cop supports safe autocorrection (--autocorrect). +# Configuration parameters: WordRegex. +# SupportedStyles: percent, brackets +Style/WordArray: + EnforcedStyle: percent + MinSize: 3 + +# Offense count: 6 +# This cop supports unsafe autocorrection (--autocorrect-all). +Style/ZeroLengthPredicate: + Exclude: + - 'app/models/stage.rb' + - 'app/services/group_stage_service.rb' + +# Offense count: 113 +# This cop supports safe autocorrection (--autocorrect). +# Configuration parameters: Max, AllowHeredoc, AllowURI, AllowQualifiedName, URISchemes, AllowRBSInlineAnnotation, AllowCopDirectives, AllowedPatterns, SplitStrings. +# URISchemes: http, https +Layout/LineLength: + Exclude: + - 'app/controllers/matches_controller.rb' + - 'app/controllers/tournaments_controller.rb' + - 'app/models/team_action_list.rb' + - 'app/services/group_stage_service.rb' + - 'app/services/playoff_stage_service.rb' + - 'db/schema.rb' + - 'e2e/lib/api_client.rb' + - 'e2e/lib/scenario_runner.rb' + - 'script/e2e_scenarios.rb' + - 'spec/controllers/groups_controller_spec.rb' + - 'spec/controllers/matches_controller_spec.rb' + - 'spec/controllers/stages_controller_spec.rb' + - 'spec/controllers/team_action_lists_controller_spec.rb' + - 'spec/e2e/http/api_surface_spec.rb' + - 'spec/e2e/http/tournament_follow_sync_spec.rb' + - 'spec/e2e/http/tournament_follow_sync_websocket_spec.rb' + - 'spec/e2e/http/tournament_lifecycle_spec.rb' + - 'spec/e2e/http/tournament_rendering_spec.rb' + - 'spec/e2e/http/tournament_sync_test_spec.rb' + - 'spec/e2e/http/tournament_team_action_lists_websocket_spec.rb' + - 'spec/e2e/http/tournament_timer_follow_sync_websocket_spec.rb' + - 'spec/factories/tournaments.rb' + - 'spec/services/group_stage_service_spec.rb' + - 'spec/services/team_action_list_service_spec.rb' + - 'spec/services/tournament_snapshot_importer_spec.rb' + - 'spec/services/tournament_sync_processor_spec.rb' 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 f44458b..7d09b59 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,8 +1,8 @@ # syntax=docker/dockerfile:1.7 -ARG RUBY_VERSION=3.1.2 -ARG BUNDLER_VERSION=2.3.13 -ARG base_commit=aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa +ARG RUBY_VERSION=4.0.3 +ARG BUNDLER_VERSION=4.0.6 +ARG GIT_COMMIT_SHA=aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa FROM ruby:${RUBY_VERSION}-slim AS runtime-base @@ -19,6 +19,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/* \ @@ -33,6 +34,7 @@ RUN apt-get update -qq \ git \ libpq-dev \ libsqlite3-dev \ + libyaml-dev \ pkg-config \ python3 \ python3-pip \ @@ -55,10 +57,10 @@ RUN bundle config set with 'development test' \ FROM runtime-base AS production -ARG base_commit=aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa +ARG GIT_COMMIT_SHA=aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa ENV RAILS_ENV=production \ - GIT_COMMIT_SHA=${base_commit} + GIT_COMMIT_SHA=${GIT_COMMIT_SHA} COPY --from=bundle-production /usr/local/bundle /usr/local/bundle COPY --from=bundle-production /app/vendor/bundle /app/vendor/bundle @@ -81,10 +83,10 @@ CMD ["sh", "-lc", "bundle exec rails db:migrate && bundle exec rails s -p 3000 - FROM runtime-base AS test -ARG base_commit=aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa +ARG GIT_COMMIT_SHA=aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa ENV RAILS_ENV=test \ - GIT_COMMIT_SHA=${base_commit} + GIT_COMMIT_SHA=${GIT_COMMIT_SHA} RUN apt-get update -qq \ && apt-get install -y --no-install-recommends \ @@ -92,7 +94,7 @@ RUN apt-get update -qq \ python3 \ python3-pip \ && rm -rf /var/lib/apt/lists/* \ - && python3 -m pip install --no-cache-dir invoke + && python3 -m pip install --break-system-packages --no-cache-dir invoke COPY --from=bundle-test /usr/local/bundle /usr/local/bundle COPY --from=bundle-test /app/vendor/bundle /app/vendor/bundle diff --git a/Gemfile b/Gemfile index 91dfd63..29fdec4 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' @@ -59,7 +59,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' end group :production do @@ -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/app/controllers/application_controller.rb b/app/controllers/application_controller.rb index 158102a..6c56364 100644 --- a/app/controllers/application_controller.rb +++ b/app/controllers/application_controller.rb @@ -10,6 +10,10 @@ class ApplicationController < ActionController::API end rescue_from ActiveRecord::RecordNotFound, with: :render_not_found_error + def bypass_sign_in(_resource, scope: nil) + true + end + protected def configure_permitted_parameters @@ -42,6 +46,11 @@ class ApplicationController < ActionController::API 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 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 3466c8e..fd78498 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/tournament.rb b/app/models/tournament.rb index 3debf27..2128b6e 100644 --- a/app/models/tournament.rb +++ b/app/models/tournament.rb @@ -4,6 +4,7 @@ require 'securerandom' class Tournament < ApplicationRecord belongs_to :user + belongs_to :owner, class_name: 'User', foreign_key: :user_id has_many :teams, dependent: :destroy has_many :stages, dependent: :destroy has_many :beamers, dependent: :destroy @@ -17,8 +18,6 @@ class Tournament < ApplicationRecord validate :playoff_teams_amount_is_positive_power_of_two - alias_attribute :owner, :user - after_initialize :generate_code after_create_commit :ensure_default_beamer! before_validation :clear_follower_sync_token_when_disabling_read_only_mode diff --git a/config/application.rb b/config/application.rb index bd16c7a..e6e619b 100644 --- a/config/application.rb +++ b/config/application.rb @@ -50,7 +50,5 @@ module TurniereBackend from: 'noreply@turnie.re' } - # Active Record - config.active_record.legacy_connection_handling = false end end diff --git a/config/environments/production.rb b/config/environments/production.rb index 3c74f73..360a4b1 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/db/schema.rb b/db/schema.rb index 002741a..011a77f 100644 --- a/db/schema.rb +++ b/db/schema.rb @@ -10,96 +10,96 @@ # # 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_04_26_121000) 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.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.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,13 +108,13 @@ 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 @@ -122,25 +122,25 @@ ActiveRecord::Schema[7.0].define(version: 2026_04_26_121000) do 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" @@ -148,51 +148,51 @@ ActiveRecord::Schema[7.0].define(version: 2026_04_26_121000) do 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.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 "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.string "sync_auth_token" - t.integer "sync_source_tournament_id" - t.datetime "sync_last_pushed_at" - t.string "sync_last_push_error" 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.datetime "timestamp" + t.datetime "updated_at", precision: nil, null: false + t.integer "user_id", null: false 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" diff --git a/docker-compose.blackbox-follow.yml b/docker-compose.blackbox-follow.yml index cfb49d9..a192dc1 100644 --- a/docker-compose.blackbox-follow.yml +++ b/docker-compose.blackbox-follow.yml @@ -43,6 +43,7 @@ services: 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" @@ -64,6 +65,7 @@ services: 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..15e5d3b 100644 --- a/docker-compose.blackbox.yml +++ b/docker-compose.blackbox.yml @@ -25,6 +25,7 @@ services: 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/spec/e2e/http/api_surface_spec.rb b/spec/e2e/http/api_surface_spec.rb index 2c1b5ed..9a7803f 100644 --- a/spec/e2e/http/api_surface_spec.rb +++ b/spec/e2e/http/api_surface_spec.rb @@ -52,7 +52,7 @@ RSpec.describe 'Backend API surface HTTP E2E' do }) 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 diff --git a/spec/rails_helper.rb b/spec/rails_helper.rb index 15c095d..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}/spec/fixtures" + config.fixture_paths = ["#{::Rails.root}/spec/fixtures"] # Run only focused tests # TODO REVERT ME diff --git a/tasks.py b/tasks.py index 3bb5306..36f6bb9 100644 --- a/tasks.py +++ b/tasks.py @@ -189,6 +189,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 +221,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, @@ -471,6 +473,7 @@ def _shared_production_env(): 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"), ) @@ -485,6 +488,7 @@ def _production_env_for(db_host, db_name): 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", ) @@ -525,7 +529,7 @@ def _run_blackbox_rspec(base_url, email, password, username, alt_email=E2E_ALT_E 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, @@ -537,7 +541,7 @@ def _start_server(command, env, log_path="/tmp/turniere-e2e-server.log"): 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(), + _env(GIT_COMMIT_SHA=_git_commit_sha()), ) @@ -603,7 +607,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=_env(GIT_COMMIT_SHA=_git_commit_sha()), + pty=True, + ) @task(name="start-local") From fcfdead11856b938ac6a03481800bc7bfca3d6f1 Mon Sep 17 00:00:00 2001 From: Malaber Date: Tue, 28 Apr 2026 23:46:00 +0200 Subject: [PATCH 06/36] feat: start group matches by catch-up position --- app/controllers/tournaments_controller.rb | 23 +++++++-- .../tournament_group_match_starter.rb | 41 +++++++++++++++ app/services/tournament_matches_payload.rb | 7 +-- config/routes.rb | 1 + .../tournaments_controller_spec.rb | 50 +++++++++++++++++++ spec/e2e/http/api_surface_spec.rb | 49 +++++++++++++++++- spec/routing/tournaments_routing_spec.rb | 18 ++++--- .../tournament_group_match_starter_spec.rb | 41 +++++++++++++++ spec/services/tournament_live_payload_spec.rb | 21 ++++++++ tasks.py | 2 +- 10 files changed, 235 insertions(+), 18 deletions(-) create mode 100644 app/services/tournament_group_match_starter.rb create mode 100644 spec/services/tournament_group_match_starter_spec.rb diff --git a/app/controllers/tournaments_controller.rb b/app/controllers/tournaments_controller.rb index bc29529..9478dc6 100644 --- a/app/controllers/tournaments_controller.rb +++ b/app/controllers/tournaments_controller.rb @@ -12,15 +12,18 @@ class TournamentsController < ApplicationController end before_action :set_tournament_for_show, only: %i[show] - before_action :set_tournament, only: %i[update destroy set_timer timer sync_state test_sync] - before_action :authenticate_user!, only: %i[create update destroy set_timer test_sync] - before_action -> { require_owner! @tournament.owner }, only: %i[update destroy set_timer test_sync] + before_action :set_tournament, + only: %i[update destroy set_timer timer sync_state test_sync start_next_group_matches] + before_action :authenticate_user!, only: %i[create update destroy set_timer test_sync start_next_group_matches] + before_action -> { require_owner! @tournament.owner }, + only: %i[update destroy set_timer test_sync start_next_group_matches] before_action :validate_create_params, only: %i[create] before_action :validate_update_params, only: %i[update] before_action :validate_set_timer_params, only: %i[set_timer] before_action :ensure_update_allowed!, only: %i[update] before_action :ensure_destroy_allowed!, only: %i[destroy] before_action :ensure_timer_update_allowed!, only: %i[set_timer] + before_action -> { require_writable_tournament!(@tournament) }, only: %i[start_next_group_matches] before_action :authorize_sync_request!, only: %i[sync_state] rescue_from ActiveRecord::RecordNotFound, with: :render_not_found_error @@ -165,6 +168,20 @@ class TournamentsController < ApplicationController }, status: :bad_gateway end + # PATCH /tournaments/:id/start_next_group_matches + def start_next_group_matches + started_matches = TournamentGroupMatchStarter.start_next!(@tournament, logger:) + if started_matches.any? + push_sync_if_needed!(@tournament) + TournamentLiveBroadcast.call(@tournament.id) + end + + render json: ActiveModelSerializers::SerializableResource.new( + started_matches, + each_serializer: LiveMatchSerializer, + include: %w[match_scores.team] + ).as_json + end private diff --git a/app/services/tournament_group_match_starter.rb b/app/services/tournament_group_match_starter.rb new file mode 100644 index 0000000..0fdd7d5 --- /dev/null +++ b/app/services/tournament_group_match_starter.rb @@ -0,0 +1,41 @@ +# frozen_string_literal: true + +class TournamentGroupMatchStarter + class << self + def upcoming_matches(tournament) + group_stage = tournament.group_stage + return [] if group_stage.nil? + + next_matches = group_stage.groups.filter_map do |group| + group.matches.not_started.order(:position).first + end + lowest_position = next_matches.map(&:position).min + return [] if lowest_position.nil? + + next_matches.select { |match| match.position == lowest_position } + .sort_by { |match| [match.position, match.group.number, match.id] } + end + + def start_next!(tournament, logger: Rails.logger) + started_matches = [] + + Match.transaction do + started_matches = upcoming_matches(tournament) + started_matches.each { |match| match.update!(state: :in_progress) } + recalculate_group_scores!(started_matches, logger:) + end + + started_matches + end + + private + + def recalculate_group_scores!(matches, logger:) + matches.map(&:group).uniq.each do |group| + next if UpdateGroupsGroupScoresAndSave.call(group:).success? + + logger.warn "Updating groups group score failed for #{group}" + end + end + end +end 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/config/routes.rb b/config/routes.rb index 945366d..a3679f9 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -34,6 +34,7 @@ Rails.application.routes.draw do get :timer patch :timer, action: :set_timer patch :sync_state + patch :start_next_group_matches post :test_sync end end diff --git a/spec/controllers/tournaments_controller_spec.rb b/spec/controllers/tournaments_controller_spec.rb index 2f95deb..4777793 100644 --- a/spec/controllers/tournaments_controller_spec.rb +++ b/spec/controllers/tournaments_controller_spec.rb @@ -888,4 +888,54 @@ RSpec.describe TournamentsController, type: :controller do expect(json.dig(:tournament, :sync_last_push_error)).to eq('connection refused') end end + + describe 'PATCH #start_next_group_matches' do + let(:tournament) { create(:group_stage_tournament, group_count: 4, match_factory: :filled_group_match) } + let(:groups) { tournament.group_stage.groups.sort_by(&:number) } + + before do + set_group_match_states(groups[0], %i[finished finished finished in_progress]) + groups.drop(1).each do |group| + set_group_match_states(group, %i[finished finished in_progress not_started]) + end + end + + context 'as owner' do + before do + apply_authentication_headers_for tournament.owner + end + + it 'starts only lowest pending catch-up matches and returns them' do + expect(TournamentLiveBroadcast).to receive(:call).with(tournament.id) + + patch :start_next_group_matches, params: { id: tournament.to_param } + + expect(response).to have_http_status(:ok) + body = deserialize_response(response) + expect(body.map { |match| match.dig(:group, :id) }).to match_array(groups.drop(1).map(&:id)) + expect(body.map { |match| match[:position] }.uniq).to eq([3]) + expect(body.map { |match| match[:state] }.uniq).to eq(['in_progress']) + expect(groups.drop(1).map { |group| group.matches.find_by!(position: 3).reload.state }.uniq) + .to eq(['in_progress']) + end + end + + context 'as another user' do + before do + apply_authentication_headers_for create(:user) + end + + it 'renders a forbidden error response' do + patch :start_next_group_matches, params: { id: tournament.to_param } + + expect(response).to have_http_status(:forbidden) + end + end + + def set_group_match_states(group, states) + group.matches.order(:id).each_with_index do |match, index| + match.update!(position: index, state: states.fetch(index)) + end + end + end end diff --git a/spec/e2e/http/api_surface_spec.rb b/spec/e2e/http/api_surface_spec.rb index 8575991..61301d5 100644 --- a/spec/e2e/http/api_surface_spec.rb +++ b/spec/e2e/http/api_surface_spec.rb @@ -376,7 +376,6 @@ RSpec.describe 'Backend API surface HTTP E2E' do group = group_stage.fetch(:groups).min_by { |item| item.fetch(:number) } ordered_matches = group.fetch(:matches).sort_by { |item| item.fetch(:position) } first_match = ordered_matches.fetch(0) - second_match = ordered_matches.fetch(1) fourth_match = ordered_matches.fetch(3) upcoming_before = owner_client.get("/tournaments/#{tournament.fetch(:id)}/matches", params: { state: 'upcoming' }) @@ -408,7 +407,48 @@ RSpec.describe 'Backend API surface HTTP E2E' do expect(upcoming_match_for_group(upcoming_after_swap, group_id: group.fetch(:id)).fetch(:id)).to eq(fourth_match.fetch(:id)) expect(upcoming_after_finish[:status]).to eq(200) - expect(upcoming_match_for_group(upcoming_after_finish, group_id: group.fetch(:id)).fetch(:id)).to eq(second_match.fetch(:id)) + expect(upcoming_match_for_group(upcoming_after_finish, group_id: group.fetch(:id))).to be_nil + expect(upcoming_after_finish[:json].map { |match| match.fetch(:position) }.uniq) + .to eq([first_match.fetch(:position)]) + end + + it 'starts next group matches at the lowest pending position and returns started matches' do + tournament = create_group_stage_tournament(client: owner_client, public: false, name_prefix: 'Start Next Groups') + groups = tournament.fetch(:stages).find { |stage| stage.fetch(:level) == -1 } + .fetch(:groups) + .sort_by { |group| group.fetch(:number) } + matches_by_group = groups.to_h do |group| + [group.fetch(:id), group.fetch(:matches).sort_by { |match| match.fetch(:position) }] + end + + first_start = owner_client.patch("/tournaments/#{tournament.fetch(:id)}/start_next_group_matches") + expect(first_start[:status]).to eq(200) + expect(first_start[:json].map { |match| match.dig(:group, :id) }) + .to match_array(groups.map { |group| group.fetch(:id) }) + expect(first_start[:json].map { |match| match.fetch(:position) }.uniq).to eq([0]) + + matches_by_group.fetch(groups.first.fetch(:id))[1..3].each do |match| + start_group_match!(match_id: match.fetch(:id)) + end + groups.drop(1).each do |group| + matches_by_group.fetch(group.fetch(:id))[1..2].each do |match| + start_group_match!(match_id: match.fetch(:id)) + end + end + + expected_match_ids = groups.drop(1).map do |group| + matches_by_group.fetch(group.fetch(:id)).fetch(3).fetch(:id) + end + upcoming = owner_client.get("/tournaments/#{tournament.fetch(:id)}/matches", params: { state: 'upcoming' }) + catch_up_start = owner_client.patch("/tournaments/#{tournament.fetch(:id)}/start_next_group_matches") + + expect(upcoming[:status]).to eq(200) + expect(upcoming[:json].map { |match| match.fetch(:id) }).to match_array(expected_match_ids) + + expect(catch_up_start[:status]).to eq(200) + expect(catch_up_start[:json].map { |match| match.fetch(:id) }).to match_array(expected_match_ids) + expect(catch_up_start[:json].map { |match| match.fetch(:position) }.uniq).to eq([3]) + expect(catch_up_start[:json].map { |match| match.fetch(:state) }.uniq).to eq(['in_progress']) end def login_client(email:, password:) @@ -506,6 +546,11 @@ RSpec.describe 'Backend API surface HTTP E2E' do expect(finish_response[:status]).to eq(200) end + def start_group_match!(match_id:) + start_response = owner_client.patch("/matches/#{match_id}", body: { state: 'in_progress' }) + expect(start_response[:status]).to eq(200) + end + def tournament_match_count(tournament) tournament.fetch(:stages).sum do |stage| stage.fetch(:matches, []).count + stage.fetch(:groups, []).sum { |group| group.fetch(:matches).count } diff --git a/spec/routing/tournaments_routing_spec.rb b/spec/routing/tournaments_routing_spec.rb index 8c30c0d..3cb398a 100644 --- a/spec/routing/tournaments_routing_spec.rb +++ b/spec/routing/tournaments_routing_spec.rb @@ -28,12 +28,18 @@ RSpec.describe TournamentsController, type: :routing do expect(delete: '/tournaments/1').to route_to('tournaments#destroy', 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 #sync_state' do + expect(patch: '/tournaments/1/sync_state').to route_to('tournaments#sync_state', id: '1') + end - it 'routes to #test_sync' do - expect(post: '/tournaments/1/test_sync').to route_to('tournaments#test_sync', id: '1') + 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 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..77f2b24 --- /dev/null +++ b/spec/services/tournament_group_match_starter_spec.rb @@ -0,0 +1,41 @@ +# frozen_string_literal: true + +require 'rails_helper' + +RSpec.describe TournamentGroupMatchStarter do + let(:tournament) { create(:group_stage_tournament, group_count: 4, match_factory: :filled_group_match) } + let(:groups) { tournament.group_stage.groups.sort_by(&:number) } + + before do + set_group_match_states(groups[0], %i[finished finished finished in_progress]) + groups.drop(1).each do |group| + set_group_match_states(group, %i[finished finished in_progress not_started]) + end + end + + describe '.upcoming_matches' do + it 'returns only groups whose next unstarted match is at the lowest pending position' do + matches = described_class.upcoming_matches(tournament) + + expect(matches.map(&:group)).to match_array(groups.drop(1)) + expect(matches.map(&:position).uniq).to eq([3]) + end + end + + describe '.start_next!' do + it 'starts selected catch-up matches and returns them' do + started_matches = described_class.start_next!(tournament, logger: Logger.new(nil)) + + expect(started_matches.map(&:group)).to match_array(groups.drop(1)) + expect(started_matches.map(&:position).uniq).to eq([3]) + expect(started_matches.map { |match| match.reload.state }.uniq).to eq(['in_progress']) + expect(groups.first.matches.find_by!(position: 3).reload).to be_in_progress + end + end + + def set_group_match_states(group, states) + group.matches.order(:id).each_with_index do |match, index| + match.update!(position: index, state: states.fetch(index)) + end + end +end diff --git a/spec/services/tournament_live_payload_spec.rb b/spec/services/tournament_live_payload_spec.rb index 21cb34b..8e5781d 100644 --- a/spec/services/tournament_live_payload_spec.rb +++ b/spec/services/tournament_live_payload_spec.rb @@ -49,6 +49,21 @@ RSpec.describe 'Tournament live payloads' do expect(matches.map(&:group_id)).to match_array(tournament.group_stage.groups.map(&:id)) end + it 'returns only catch-up group matches for upcoming state' do + tournament = create(:group_stage_tournament, group_count: 4, match_factory: :filled_group_match) + groups = tournament.group_stage.groups.sort_by(&:number) + + set_group_match_states(groups[0], %i[finished finished finished in_progress]) + groups.drop(1).each do |group| + set_group_match_states(group, %i[finished finished in_progress not_started]) + end + + matches = described_class.matches_for(tournament, state: 'upcoming') + + expect(matches.map(&:group_id)).to match_array(groups.drop(1).map(&:id)) + expect(matches.map(&:position).uniq).to eq([3]) + end + it 'filters all matches belonging to a stage level' do tournament = create(:stage_tournament, stage_count: 2) stage = tournament.stages.find_by!(level: 1) @@ -106,4 +121,10 @@ RSpec.describe 'Tournament live payloads' do ) end end + + def set_group_match_states(group, states) + group.matches.order(:id).each_with_index do |match, index| + match.update!(position: index, state: states.fetch(index)) + end + end end diff --git a/tasks.py b/tasks.py index 69986f7..1dc45bd 100644 --- a/tasks.py +++ b/tasks.py @@ -537,7 +537,7 @@ def _start_server(command, env, log_path="/tmp/turniere-e2e-server.log"): 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(), + _env(GIT_COMMIT_SHA=_git_commit_sha()), ) From 7461ae27aab74ec185493863d5eee5d2b311e9e0 Mon Sep 17 00:00:00 2001 From: Malaber Date: Tue, 28 Apr 2026 23:51:36 +0200 Subject: [PATCH 07/36] Restore image versioning and auth safety --- .gitlab-ci.yml | 3 -- Dockerfile | 10 ++-- app/controllers/application_controller.rb | 4 -- doc/rails_8_dependency_update.md | 58 +++++++++++++++++++++++ tasks.py | 4 +- 5 files changed, 65 insertions(+), 14 deletions(-) create mode 100644 doc/rails_8_dependency_update.md diff --git a/.gitlab-ci.yml b/.gitlab-ci.yml index 2042e34..7a52e46 100644 --- a/.gitlab-ci.yml +++ b/.gitlab-ci.yml @@ -56,7 +56,6 @@ rails e2e spec: - name: $CI_REGISTRY_IMAGE/production/commits:$CI_COMMIT_SHA alias: app variables: - GIT_COMMIT_SHA: $CI_COMMIT_SHA TURNIERE_DISABLE_EMAIL_DELIVERY: "1" command: - bundle @@ -128,7 +127,6 @@ rails follow sync e2e spec: 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 - GIT_COMMIT_SHA: $CI_COMMIT_SHA 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 @@ -153,7 +151,6 @@ rails follow sync e2e spec: 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 - GIT_COMMIT_SHA: $CI_COMMIT_SHA 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 diff --git a/Dockerfile b/Dockerfile index 7d09b59..e5eda29 100644 --- a/Dockerfile +++ b/Dockerfile @@ -2,7 +2,7 @@ ARG RUBY_VERSION=4.0.3 ARG BUNDLER_VERSION=4.0.6 -ARG GIT_COMMIT_SHA=aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa +ARG base_commit=aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa FROM ruby:${RUBY_VERSION}-slim AS runtime-base @@ -57,10 +57,10 @@ 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 \ - GIT_COMMIT_SHA=${GIT_COMMIT_SHA} + GIT_COMMIT_SHA=${base_commit} COPY --from=bundle-production /usr/local/bundle /usr/local/bundle COPY --from=bundle-production /app/vendor/bundle /app/vendor/bundle @@ -83,10 +83,10 @@ CMD ["sh", "-lc", "bundle exec rails db:migrate && bundle exec rails s -p 3000 - FROM runtime-base AS test -ARG GIT_COMMIT_SHA=aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa +ARG base_commit=aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa ENV RAILS_ENV=test \ - GIT_COMMIT_SHA=${GIT_COMMIT_SHA} + GIT_COMMIT_SHA=${base_commit} RUN apt-get update -qq \ && apt-get install -y --no-install-recommends \ diff --git a/app/controllers/application_controller.rb b/app/controllers/application_controller.rb index 6c56364..f13d19f 100644 --- a/app/controllers/application_controller.rb +++ b/app/controllers/application_controller.rb @@ -10,10 +10,6 @@ class ApplicationController < ActionController::API end rescue_from ActiveRecord::RecordNotFound, with: :render_not_found_error - def bypass_sign_in(_resource, scope: nil) - true - end - protected def configure_permitted_parameters diff --git a/doc/rails_8_dependency_update.md b/doc/rails_8_dependency_update.md new file mode 100644 index 0000000..e0c2ec3 --- /dev/null +++ b/doc/rails_8_dependency_update.md @@ -0,0 +1,58 @@ +# 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. + +## 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/tasks.py b/tasks.py index 36f6bb9..299b6a3 100644 --- a/tasks.py +++ b/tasks.py @@ -256,7 +256,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", @@ -274,7 +274,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", From 08070fb87b25cb44bf63d6dbbdef53b709e8c59e Mon Sep 17 00:00:00 2001 From: Malaber Date: Wed, 29 Apr 2026 00:06:59 +0200 Subject: [PATCH 08/36] Disable token auth session bypass --- config/initializers/devise_token_auth.rb | 1 + doc/rails_8_dependency_update.md | 6 ++++++ 2 files changed, 7 insertions(+) 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/doc/rails_8_dependency_update.md b/doc/rails_8_dependency_update.md index e0c2ec3..d67bc1f 100644 --- a/doc/rails_8_dependency_update.md +++ b/doc/rails_8_dependency_update.md @@ -9,6 +9,12 @@ 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. From 11b15da2158f017089f84307713b2a52ec23c976 Mon Sep 17 00:00:00 2001 From: Malaber Date: Wed, 29 Apr 2026 00:32:46 +0200 Subject: [PATCH 09/36] Fix controller auth specs --- spec/auth_helpers.rb | 1 + 1 file changed, 1 insertion(+) diff --git a/spec/auth_helpers.rb b/spec/auth_helpers.rb index ea55d1f..7b903a5 100644 --- a/spec/auth_helpers.rb +++ b/spec/auth_helpers.rb @@ -4,5 +4,6 @@ module AuthHelpers def apply_authentication_headers_for(user) user_headers = user.create_new_auth_token request.headers.merge!(user_headers) + sign_in(user) if respond_to?(:sign_in) end end From 2ababaa69594b59014cfe4e936f8eaf9aef78cdc Mon Sep 17 00:00:00 2001 From: Malaber Date: Wed, 29 Apr 2026 08:10:40 +0200 Subject: [PATCH 10/36] Scope controller test sign in --- spec/auth_helpers.rb | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/spec/auth_helpers.rb b/spec/auth_helpers.rb index 7b903a5..f4dabb8 100644 --- a/spec/auth_helpers.rb +++ b/spec/auth_helpers.rb @@ -4,6 +4,6 @@ module AuthHelpers def apply_authentication_headers_for(user) user_headers = user.create_new_auth_token request.headers.merge!(user_headers) - sign_in(user) if respond_to?(:sign_in) + sign_in(user, scope: :user) if respond_to?(:sign_in) end end From 6cce4f9cff8e52ae6682b255abf67c2708926366 Mon Sep 17 00:00:00 2001 From: Malaber Date: Wed, 29 Apr 2026 08:32:19 +0200 Subject: [PATCH 11/36] Keep controller specs off sessions --- spec/auth_helpers.rb | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/spec/auth_helpers.rb b/spec/auth_helpers.rb index f4dabb8..e564cce 100644 --- a/spec/auth_helpers.rb +++ b/spec/auth_helpers.rb @@ -4,6 +4,9 @@ module AuthHelpers def apply_authentication_headers_for(user) user_headers = user.create_new_auth_token request.headers.merge!(user_headers) - sign_in(user, scope: :user) if respond_to?(:sign_in) + 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 From 1bedc872103e7edeff452a532d5bf7814e50ab16 Mon Sep 17 00:00:00 2001 From: Malaber Date: Wed, 29 Apr 2026 09:44:06 +0200 Subject: [PATCH 12/36] feat(metrics): add Prometheus metrics endpoint --- app/channels/application_cable/connection.rb | 7 + app/controllers/application_controller.rb | 18 ++ app/controllers/metrics_controller.rb | 8 + app/services/metrics_registry.rb | 212 +++++++++++++++++++ config/routes.rb | 1 + doc/metrics.md | 20 ++ spec/e2e/http/metrics_spec.rb | 97 +++++++++ spec/requests/metrics_endpoint_spec.rb | 28 +++ spec/routing/metrics_routing_spec.rb | 11 + spec/services/metrics_registry_spec.rb | 59 ++++++ 10 files changed, 461 insertions(+) create mode 100644 app/controllers/metrics_controller.rb create mode 100644 app/services/metrics_registry.rb create mode 100644 doc/metrics.md create mode 100644 spec/e2e/http/metrics_spec.rb create mode 100644 spec/requests/metrics_endpoint_spec.rb create mode 100644 spec/routing/metrics_routing_spec.rb create mode 100644 spec/services/metrics_registry_spec.rb 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/application_controller.rb b/app/controllers/application_controller.rb index 158102a..b92a017 100644 --- a/app/controllers/application_controller.rb +++ b/app/controllers/application_controller.rb @@ -3,6 +3,7 @@ class ApplicationController < ActionController::API include DeviseTokenAuth::Concerns::SetUserByToken + around_action :record_request_metrics before_action :configure_permitted_parameters, if: :devise_controller? rescue_from ActionController::ParameterMissing do |e| @@ -19,6 +20,23 @@ class ApplicationController < ActionController::API private + def record_request_metrics + start_time = Process.clock_gettime(Process::CLOCK_MONOTONIC) + metrics_status = nil + yield + rescue StandardError => e + metrics_status = ActionDispatch::ExceptionWrapper.status_code_for_exception(e.class.name) + raise + ensure + MetricsRegistry.record_http_request( + method: request.request_method, + controller: controller_path, + action: action_name, + status: metrics_status || response.status, + duration: Process.clock_gettime(Process::CLOCK_MONOTONIC) - start_time + ) + end + def require_owner!(owner) render_forbidden_error if owner != current_user end 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/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/config/routes.rb b/config/routes.rb index 945366d..74b165e 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -39,5 +39,6 @@ Rails.application.routes.draw do 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/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/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/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/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/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 From 6df5cb25029eaef9264d472e4c10d0964b16eeb5 Mon Sep 17 00:00:00 2001 From: Malaber Date: Wed, 29 Apr 2026 15:48:20 +0200 Subject: [PATCH 13/36] fix(ci): restore Rails 8 spec suite Confirm factory users for real Devise auth in request specs and use Rails 8-compatible status and route calls to remove deprecation warnings. --- app/controllers/beamers_controller.rb | 4 +- app/controllers/bets_controller.rb | 2 +- app/controllers/match_scores_controller.rb | 2 +- app/controllers/matches_controller.rb | 18 +++---- app/controllers/stages_controller.rb | 14 ++--- .../team_action_items_controller.rb | 2 +- .../team_action_lists_controller.rb | 6 +-- app/controllers/teams_controller.rb | 2 +- app/controllers/tournaments_controller.rb | 28 +++++----- .../devise_routes_rails_8_compat.rb | 53 +++++++++++++++++++ ...8120000_add_timer_reason_to_tournaments.rb | 2 + spec/controllers/beamers_controller_spec.rb | 6 +-- .../match_scores_controller_spec.rb | 2 +- spec/controllers/matches_controller_spec.rb | 16 +++--- spec/controllers/stages_controller_spec.rb | 10 ++-- .../team_action_lists_controller_spec.rb | 6 +-- .../tournaments_controller_spec.rb | 28 +++++----- spec/e2e/http/api_surface_spec.rb | 6 +-- spec/factories/users.rb | 1 + .../tournament_snapshot_importer_spec.rb | 4 ++ 20 files changed, 136 insertions(+), 76 deletions(-) create mode 100644 config/initializers/devise_routes_rails_8_compat.rb 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..502da1f 100644 --- a/app/controllers/match_scores_controller.rb +++ b/app/controllers/match_scores_controller.rb @@ -19,7 +19,7 @@ class MatchScoresController < ApplicationController TournamentLiveBroadcast.call(@match_score.tournament.id) render json: @match_score else - render json: @match_score.errors, status: :unprocessable_entity + render json: @match_score.errors, status: :unprocessable_content end end diff --git a/app/controllers/matches_controller.rb b/app/controllers/matches_controller.rb index a2cdb01..ac40b8f 100644 --- a/app/controllers/matches_controller.rb +++ b/app/controllers/matches_controller.rb @@ -26,14 +26,14 @@ class MatchesController < ApplicationController match = build_decider_match if match.errors.any? - render json: match.errors, status: :unprocessable_entity + render json: match.errors, status: :unprocessable_content 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 + render json: match.errors, status: :unprocessable_content end end @@ -55,7 +55,7 @@ class MatchesController < ApplicationController render json: @match synced_tournament = @match.tournament else - render json: @match.errors, status: :unprocessable_entity + render json: @match.errors, status: :unprocessable_content raise ActiveRecord::Rollback end end @@ -66,7 +66,7 @@ class MatchesController < ApplicationController # 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 + render json: { error: 'Matches must be different group matches from the same group' }, status: :unprocessable_content return end @@ -102,27 +102,27 @@ class MatchesController < ApplicationController 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? 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 \ + render json: { error: 'Match can\'t start in this state' }, status: :unprocessable_content \ unless @match.not_started? when 'finished' - render json: { error: 'Match can\'t finish in this state' }, status: :unprocessable_entity \ + render json: { error: 'Match can\'t finish in this state' }, status: :unprocessable_content \ unless @match.in_progress? else - render json: { error: 'Invalid target state' }, status: :unprocessable_entity + render json: { error: 'Invalid target state' }, status: :unprocessable_content end end diff --git a/app/controllers/stages_controller.rb b/app/controllers/stages_controller.rb index f6bdf0e..972b159 100644 --- a/app/controllers/stages_controller.rb +++ b/app/controllers/stages_controller.rb @@ -15,7 +15,7 @@ class StagesController < ApplicationController 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 + render json: { error: 'Only running group stages can be finished' }, status: :unprocessable_content return end @@ -27,7 +27,7 @@ class StagesController < ApplicationController render json: @stage synced_tournament = @stage.tournament else - render json: @stage.errors, status: :unprocessable_entity + render json: @stage.errors, status: :unprocessable_content raise ActiveRecord::Rollback end end @@ -36,7 +36,7 @@ class StagesController < ApplicationController else render json: { error: 'The state attribute may only be changed to finished' - }, status: :unprocessable_entity + }, status: :unprocessable_content end end @@ -46,14 +46,14 @@ class StagesController < ApplicationController 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 + }, status: :unprocessable_content raise ActiveRecord::Rollback end @stage.groups.each do |group| next if UpdateGroupsGroupScoresAndSave.call(group: group).success? - render json: { error: 'Updating group scores failed' }, status: :unprocessable_entity + render json: { error: 'Updating group scores failed' }, status: :unprocessable_content raise ActiveRecord::Rollback end @@ -62,14 +62,14 @@ class StagesController < ApplicationController render json: { error: 'Playoffs cannot be generated yet because a decider match is required for an advancing place', blocking_ties: blocking_ties - }, status: :unprocessable_entity + }, status: :unprocessable_content 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 + render json: { error: 'Generating group stage failed' }, status: :unprocessable_content raise ActiveRecord::Rollback 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..d6e07c4 100644 --- a/app/controllers/teams_controller.rb +++ b/app/controllers/teams_controller.rb @@ -17,7 +17,7 @@ class TeamsController < ApplicationController push_sync_if_needed!(@team.tournament) render json: @team else - render json: @team.errors, status: :unprocessable_entity + render json: @team.errors, status: :unprocessable_content end end diff --git a/app/controllers/tournaments_controller.rb b/app/controllers/tournaments_controller.rb index 9478dc6..11cf0f2 100644 --- a/app/controllers/tournaments_controller.rb +++ b/app/controllers/tournaments_controller.rb @@ -67,7 +67,7 @@ class TournamentsController < ApplicationController if tournament.save 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 @@ -90,14 +90,14 @@ class TournamentsController < ApplicationController 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? 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 @@ -116,7 +116,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 @@ -139,7 +139,7 @@ class TournamentsController < ApplicationController push_sync_if_needed!(@tournament) render json: @tournament else - render json: @tournament.errors, status: :unprocessable_entity + render json: @tournament.errors, status: :unprocessable_content end end @@ -148,14 +148,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) @@ -276,7 +276,7 @@ class TournamentsController < ApplicationController teams = params['teams'] return if teams.is_a?(Array) && teams.reject { |t| t.is_a? ActionController::Parameters }.count.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 @@ -307,14 +307,14 @@ 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 params.require(:snapshot).permit!.to_h end def authorize_sync_request! - return render json: { error: 'Tournament is not configured to accept sync pushes' }, status: :unprocessable_entity unless @tournament.sync_accepts_push? + return render json: { error: 'Tournament is not configured to accept sync pushes' }, status: :unprocessable_content unless @tournament.sync_accepts_push? 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? @@ -448,12 +448,12 @@ class TournamentsController < ApplicationController return 'timer_reason_text is required for custom timer_reason' end - if timer_reason != 'custom' && timer_reason_text.present? - 'timer_reason_text is only allowed when timer_reason is custom' - end + return unless timer_reason != 'custom' && timer_reason_text.present? + + 'timer_reason_text is only allowed when timer_reason is custom' end def render_timer_validation_error(message) - render json: { error: message }, status: :unprocessable_entity + render json: { error: message }, status: :unprocessable_content end end 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..d460935 --- /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: [:new, :create, :edit, :update], + path: mapping.path_names[:password], + controller: controllers[:passwords] + end + + def devise_confirmation(mapping, controllers) + resource :confirmation, + only: [: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: [:new, :create, :show], + path: mapping.path_names[:unlock], + controller: controllers[:unlocks] + end + + def devise_registration(mapping, controllers) + resource :registration, + only: [: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/db/migrate/20260428120000_add_timer_reason_to_tournaments.rb b/db/migrate/20260428120000_add_timer_reason_to_tournaments.rb index 54cb911..f28bafe 100644 --- a/db/migrate/20260428120000_add_timer_reason_to_tournaments.rb +++ b/db/migrate/20260428120000_add_timer_reason_to_tournaments.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + class AddTimerReasonToTournaments < ActiveRecord::Migration[7.0] def change add_column :tournaments, :timer_reason, :string diff --git a/spec/controllers/beamers_controller_spec.rb b/spec/controllers/beamers_controller_spec.rb index 3a4fb46..5b58f0a 100644 --- a/spec/controllers/beamers_controller_spec.rb +++ b/spec/controllers/beamers_controller_spec.rb @@ -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 diff --git a/spec/controllers/match_scores_controller_spec.rb b/spec/controllers/match_scores_controller_spec.rb index f9cec18..4a23db8 100644 --- a/spec/controllers/match_scores_controller_spec.rb +++ b/spec/controllers/match_scores_controller_spec.rb @@ -62,7 +62,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 099f5b0..96253c2 100644 --- a/spec/controllers/matches_controller_spec.rb +++ b/spec/controllers/matches_controller_spec.rb @@ -120,7 +120,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 @@ -167,7 +167,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 @@ -288,7 +288,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 @@ -321,7 +321,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 @@ -341,7 +341,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 @@ -375,14 +375,14 @@ 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 end @@ -416,7 +416,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 9311ea7..bd7ba8c 100644 --- a/spec/controllers/stages_controller_spec.rb +++ b/spec/controllers/stages_controller_spec.rb @@ -122,7 +122,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 +145,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 +164,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 @@ -197,7 +197,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 @@ -233,7 +233,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/tournaments_controller_spec.rb b/spec/controllers/tournaments_controller_spec.rb index 4777793..89c32e5 100644 --- a/spec/controllers/tournaments_controller_spec.rb +++ b/spec/controllers/tournaments_controller_spec.rb @@ -308,21 +308,21 @@ RSpec.describe TournamentsController, type: :controller do 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_entity) + 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_entity) + 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 @@ -334,7 +334,7 @@ RSpec.describe TournamentsController, type: :controller do timer_reason_text: 'Extra info' } - expect(response).to have_http_status(:unprocessable_entity) + 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' ) @@ -466,7 +466,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) @@ -522,7 +522,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 @@ -540,7 +540,7 @@ RSpec.describe TournamentsController, type: :controller do 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 @@ -549,7 +549,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 @@ -637,18 +637,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 @@ -667,7 +667,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 @@ -720,7 +720,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 @@ -863,7 +863,7 @@ RSpec.describe TournamentsController, type: :controller do 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 diff --git a/spec/e2e/http/api_surface_spec.rb b/spec/e2e/http/api_surface_spec.rb index 02c6c3b..b63963c 100644 --- a/spec/e2e/http/api_surface_spec.rb +++ b/spec/e2e/http/api_surface_spec.rb @@ -154,9 +154,9 @@ RSpec.describe 'Backend API surface HTTP E2E' do timer_reason: 'ceremony' }) timer_custom_without_text = owner_client.patch("/tournaments/#{tournament.fetch(:id)}/timer", body: { - timestamp_seconds: 120, - timer_reason: 'custom' - }) + timestamp_seconds: 120, + timer_reason: 'custom' + }) timer_invalid_seconds = owner_client.patch("/tournaments/#{tournament.fetch(:id)}/timer", body: { timestamp_seconds: 'nope' }) 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/services/tournament_snapshot_importer_spec.rb b/spec/services/tournament_snapshot_importer_spec.rb index 14f3795..b86e6fe 100644 --- a/spec/services/tournament_snapshot_importer_spec.rb +++ b/spec/services/tournament_snapshot_importer_spec.rb @@ -80,6 +80,7 @@ RSpec.describe TournamentSnapshotImporter do end end + # rubocop:disable Metrics/MethodLength def create_large_source_tournament! tournament = create( :tournament, @@ -142,6 +143,7 @@ RSpec.describe TournamentSnapshotImporter do ) tournament.reload end + # rubocop:enable Metrics/MethodLength def create_group_round_robin!(group:, teams:) pairings = [ @@ -190,6 +192,7 @@ RSpec.describe TournamentSnapshotImporter do end end + # rubocop:disable Metrics/MethodLength def sync_signature(tournament) { name: tournament.name, @@ -244,6 +247,7 @@ RSpec.describe TournamentSnapshotImporter do end } end + # rubocop:enable Metrics/MethodLength def match_signature(match) { From 7b8fc67593f9807bd34796c9b7b98bc8c84283cb Mon Sep 17 00:00:00 2001 From: Malaber Date: Wed, 29 Apr 2026 17:02:10 +0200 Subject: [PATCH 14/36] fix(docker): skip demo seeds in production --- Dockerfile | 6 ++---- config/application.rb | 1 + db/seeds.rb | 50 +++++++++++++++++++++++++------------------ 3 files changed, 32 insertions(+), 25 deletions(-) diff --git a/Dockerfile b/Dockerfile index e5eda29..4d132e8 100644 --- a/Dockerfile +++ b/Dockerfile @@ -37,7 +37,6 @@ RUN apt-get update -qq \ libyaml-dev \ pkg-config \ python3 \ - python3-pip \ && rm -rf /var/lib/apt/lists/* COPY Gemfile Gemfile.lock ./ @@ -92,9 +91,8 @@ 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 --break-system-packages --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 diff --git a/config/application.rb b/config/application.rb index e6e619b..dbdbb57 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 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 From 6fc7be1fe65213d27bb29ea2cc8417a97ac0649a Mon Sep 17 00:00:00 2001 From: Malaber Date: Wed, 29 Apr 2026 17:08:39 +0200 Subject: [PATCH 15/36] chore(docker): use noninteractive apt --- Dockerfile | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/Dockerfile b/Dockerfile index 4d132e8..7f0d26b 100644 --- a/Dockerfile +++ b/Dockerfile @@ -7,6 +7,7 @@ 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 \ @@ -28,6 +29,8 @@ 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 \ @@ -83,6 +86,7 @@ CMD ["sh", "-lc", "bundle exec rails db:migrate && bundle exec rails s -p 3000 - FROM runtime-base AS test ARG base_commit=aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa +ARG DEBIAN_FRONTEND=noninteractive ENV RAILS_ENV=test \ GIT_COMMIT_SHA=${base_commit} From b2064a03b6a79901d88e92f2becb60c2e821da8d Mon Sep 17 00:00:00 2001 From: Malaber Date: Wed, 29 Apr 2026 19:37:28 +0200 Subject: [PATCH 17/36] fix(group matches): start lowest pending wave --- .../tournament_group_match_starter.rb | 31 +++-- spec/e2e/http/api_surface_spec.rb | 109 +++++++++++++----- .../tournament_group_match_starter_spec.rb | 54 +++++++-- 3 files changed, 142 insertions(+), 52 deletions(-) diff --git a/app/services/tournament_group_match_starter.rb b/app/services/tournament_group_match_starter.rb index 0fdd7d5..a6575c8 100644 --- a/app/services/tournament_group_match_starter.rb +++ b/app/services/tournament_group_match_starter.rb @@ -3,17 +3,11 @@ class TournamentGroupMatchStarter class << self def upcoming_matches(tournament) - group_stage = tournament.group_stage - return [] if group_stage.nil? - - next_matches = group_stage.groups.filter_map do |group| - group.matches.not_started.order(:position).first - end - lowest_position = next_matches.map(&:position).min + group_matches = not_started_group_matches(tournament) + lowest_position = group_matches.minimum(:position) return [] if lowest_position.nil? - next_matches.select { |match| match.position == lowest_position } - .sort_by { |match| [match.position, match.group.number, match.id] } + selected_lowest_matches(group_matches, lowest_position) end def start_next!(tournament, logger: Rails.logger) @@ -30,6 +24,25 @@ class TournamentGroupMatchStarter 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? diff --git a/spec/e2e/http/api_surface_spec.rb b/spec/e2e/http/api_surface_spec.rb index b63963c..4938578 100644 --- a/spec/e2e/http/api_surface_spec.rb +++ b/spec/e2e/http/api_surface_spec.rb @@ -412,43 +412,54 @@ RSpec.describe 'Backend API surface HTTP E2E' do .to eq([first_match.fetch(:position)]) end - it 'starts next group matches at the lowest pending position and returns started matches' do + 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 = tournament.fetch(:stages).find { |stage| stage.fetch(:level) == -1 } - .fetch(:groups) - .sort_by { |group| group.fetch(:number) } - matches_by_group = groups.to_h do |group| - [group.fetch(:id), group.fetch(:matches).sort_by { |match| match.fetch(:position) }] - end + 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_start = owner_client.patch("/tournaments/#{tournament.fetch(:id)}/start_next_group_matches") - expect(first_start[:status]).to eq(200) - expect(first_start[:json].map { |match| match.dig(:group, :id) }) - .to match_array(groups.map { |group| group.fetch(:id) }) - expect(first_start[:json].map { |match| match.fetch(:position) }.uniq).to eq([0]) - - matches_by_group.fetch(groups.first.fetch(:id))[1..3].each do |match| - start_group_match!(match_id: match.fetch(:id)) - end - groups.drop(1).each do |group| - matches_by_group.fetch(group.fetch(:id))[1..2].each do |match| - start_group_match!(match_id: match.fetch(:id)) - end - end - - expected_match_ids = groups.drop(1).map do |group| - matches_by_group.fetch(group.fetch(:id)).fetch(3).fetch(:id) - end + 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' }) - catch_up_start = owner_client.patch("/tournaments/#{tournament.fetch(:id)}/start_next_group_matches") + 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(upcoming[:status]).to eq(200) - expect(upcoming[:json].map { |match| match.fetch(:id) }).to match_array(expected_match_ids) + 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 - expect(catch_up_start[:status]).to eq(200) - expect(catch_up_start[:json].map { |match| match.fetch(:id) }).to match_array(expected_match_ids) - expect(catch_up_start[:json].map { |match| match.fetch(:position) }.uniq).to eq([3]) - expect(catch_up_start[:json].map { |match| match.fetch(:state) }.uniq).to eq(['in_progress']) + 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:) @@ -505,6 +516,40 @@ RSpec.describe 'Backend API surface HTTP E2E' do fetch_tournament(client: client, tournament_id: response.dig(:json, :id)) end + 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 create_playoff_tournament(client:, public:, name_prefix:) payload = { name: "#{name_prefix} #{unique_suffix}", diff --git a/spec/services/tournament_group_match_starter_spec.rb b/spec/services/tournament_group_match_starter_spec.rb index 77f2b24..1c0f662 100644 --- a/spec/services/tournament_group_match_starter_spec.rb +++ b/spec/services/tournament_group_match_starter_spec.rb @@ -7,29 +7,50 @@ RSpec.describe TournamentGroupMatchStarter do 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 + 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([3]) + expect(matches.map(&:position).uniq).to eq([2]) end end describe '.start_next!' do - it 'starts selected catch-up matches and returns them' do - started_matches = described_class.start_next!(tournament, logger: Logger.new(nil)) + 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]) - expect(started_matches.map(&:group)).to match_array(groups.drop(1)) - expect(started_matches.map(&:position).uniq).to eq([3]) - expect(started_matches.map { |match| match.reload.state }.uniq).to eq(['in_progress']) - expect(groups.first.matches.find_by!(position: 3).reload).to be_in_progress + 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 @@ -38,4 +59,15 @@ RSpec.describe TournamentGroupMatchStarter do 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 From df227f9397fe72356627297e0e0948542d8c8a8a Mon Sep 17 00:00:00 2001 From: Malaber Date: Wed, 29 Apr 2026 19:56:25 +0200 Subject: [PATCH 18/36] feat(matches): stop in-progress matches --- app/controllers/matches_controller.rb | 24 +++++++--- spec/controllers/matches_controller_spec.rb | 51 +++++++++++++++++++++ spec/e2e/http/api_surface_spec.rb | 14 +++++- 3 files changed, 82 insertions(+), 7 deletions(-) diff --git a/app/controllers/matches_controller.rb b/app/controllers/matches_controller.rb index ac40b8f..8a83897 100644 --- a/app/controllers/matches_controller.rb +++ b/app/controllers/matches_controller.rb @@ -45,12 +45,8 @@ class MatchesController < ApplicationController 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 - unless UpdateGroupsGroupScoresAndSave.call(group: group).success? - logger.warn "Updating groups group score failed for #{group}" - end - end + handle_match_stop if new_state == 'not_started' + update_group_scores if @match.group_match? && %w[in_progress not_started].include?(new_state) render json: @match synced_tournament = @match.tournament @@ -113,6 +109,19 @@ class MatchesController < ApplicationController raise ActiveRecord::Rollback end + def handle_match_stop + @match.match_scores.each do |match_score| + match_score.update!(points: 0, hidden_points: 0) + end + end + + def update_group_scores + group = @match.group + return if UpdateGroupsGroupScoresAndSave.call(group: group).success? + + logger.warn "Updating groups group score failed for #{group}" + end + def validate_params case match_params['state'] when 'in_progress' @@ -121,6 +130,9 @@ class MatchesController < ApplicationController when 'finished' render json: { error: 'Match can\'t finish in this state' }, status: :unprocessable_content \ unless @match.in_progress? + when 'not_started' + render json: { error: 'Match can\'t stop in this state' }, status: :unprocessable_content \ + unless @match.in_progress? else render json: { error: 'Invalid target state' }, status: :unprocessable_content end diff --git a/spec/controllers/matches_controller_spec.rb b/spec/controllers/matches_controller_spec.rb index 96253c2..ed1a5a0 100644 --- a/spec/controllers/matches_controller_spec.rb +++ b/spec/controllers/matches_controller_spec.rb @@ -233,6 +233,21 @@ RSpec.describe MatchesController, type: :controller do expect(body[:state]).to eq(valid_update[:state]) 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 { @@ -385,6 +400,14 @@ RSpec.describe MatchesController, type: :controller do 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 context 'as another user' do @@ -401,6 +424,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 { diff --git a/spec/e2e/http/api_surface_spec.rb b/spec/e2e/http/api_surface_spec.rb index b63963c..d72a158 100644 --- a/spec/e2e/http/api_surface_spec.rb +++ b/spec/e2e/http/api_surface_spec.rb @@ -270,11 +270,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 }) 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) } @@ -349,12 +352,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({}) From 3627c1a3ce1ef88e316e264058f6931220e6c2be Mon Sep 17 00:00:00 2001 From: Malaber Date: Wed, 29 Apr 2026 22:34:32 +0200 Subject: [PATCH 19/36] feat(load): add load smoke tests --- .gitlab-ci.yml | 60 ++++++ doc/load_tests.md | 39 ++++ e2e/lib/load_test_runner.rb | 342 ++++++++++++++++++++++++++++++++ e2e/lib/scenario_runner.rb | 21 +- e2e/load_test.rb | 104 ++++++++++ spec/e2e/http/load_test_spec.rb | 36 ++++ tasks.py | 216 ++++++++++++++++++++ 7 files changed, 811 insertions(+), 7 deletions(-) create mode 100644 doc/load_tests.md create mode 100644 e2e/lib/load_test_runner.rb create mode 100755 e2e/load_test.rb create mode 100644 spec/e2e/http/load_test_spec.rb diff --git a/.gitlab-ci.yml b/.gitlab-ci.yml index 7a52e46..99034f1 100644 --- a/.gitlab-ci.yml +++ b/.gitlab-ci.yml @@ -98,6 +98,66 @@ 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" + command: + - bundle + - exec + - rails + - s + - -p + - "3000" + - -b + - 0.0.0.0 + variables: + FF_NETWORK_PER_BUILD: "true" + POSTGRES_DB: turniere_blackbox + POSTGRES_USER: turniere + POSTGRES_PASSWORD: turniere + 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: 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/e2e/lib/load_test_runner.rb b/e2e/lib/load_test_runner.rb new file mode 100644 index 0000000..ddcb77f --- /dev/null +++ b/e2e/lib/load_test_runner.rb @@ -0,0 +1,342 @@ +# 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 = [] + if level.fetch(:errors).positive? + failures << "WS #{level.fetch(:clients)} clients: #{level.fetch(:errors)} failed" + end + if websocket_open_short?(level) + failures << "WS #{level.fetch(:clients)} clients: only #{level.fetch(:opened)} opened" + end + 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 664db82..f3d3eb5 100644 --- a/e2e/lib/scenario_runner.rb +++ b/e2e/lib/scenario_runner.rb @@ -206,6 +206,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') @@ -560,10 +574,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}") - raise ApiError.new('login succeeded but no auth headers were returned', status: 200, body: login_response.fetch(:json)) unless client.authenticated? - - { 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/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/tasks.py b/tasks.py index 299b6a3..c1f323a 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" @@ -526,6 +534,75 @@ 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( @@ -808,6 +885,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.""" @@ -1744,6 +1907,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, From bc928688addfc5e5546d2b09a05a082277c47dc0 Mon Sep 17 00:00:00 2001 From: Malaber Date: Thu, 30 Apr 2026 08:58:22 +0200 Subject: [PATCH 20/36] feat(docker): make scaling env configurable --- .gitlab-ci.yml | 28 ++++-- Dockerfile | 2 +- config/database.yml | 8 +- config/puma.rb | 13 +-- config/sidekiq.yml | 2 + docker-compose.blackbox-follow.yml | 12 +++ docker-compose.blackbox.yml | 6 ++ spec/config/scaling_env_spec.rb | 148 +++++++++++++++++++++++++++++ tasks.py | 121 ++++++++++++++++++++++- 9 files changed, 318 insertions(+), 22 deletions(-) create mode 100644 config/sidekiq.yml create mode 100644 spec/config/scaling_env_spec.rb diff --git a/.gitlab-ci.yml b/.gitlab-ci.yml index 99034f1..7c37abf 100644 --- a/.gitlab-ci.yml +++ b/.gitlab-ci.yml @@ -57,13 +57,15 @@ rails e2e spec: alias: app variables: TURNIERE_DISABLE_EMAIL_DELIVERY: "1" + WEB_CONCURRENCY: "2" + RAILS_MAX_THREADS: "5" + RAILS_DB_POOL: "5" + SIDEKIQ_CONCURRENCY: "5" command: - bundle - exec - rails - s - - -p - - "3000" - -b - 0.0.0.0 variables: @@ -71,6 +73,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 @@ -112,13 +115,15 @@ rails load smoke: alias: app variables: TURNIERE_DISABLE_EMAIL_DELIVERY: "1" + WEB_CONCURRENCY: "2" + RAILS_MAX_THREADS: "5" + RAILS_DB_POOL: "5" + SIDEKIQ_CONCURRENCY: "5" command: - bundle - exec - rails - s - - -p - - "3000" - -b - 0.0.0.0 variables: @@ -126,6 +131,7 @@ rails load smoke: 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 @@ -181,6 +187,7 @@ 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 @@ -193,18 +200,21 @@ rails follow sync e2e spec: 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: "2" + 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 @@ -217,13 +227,15 @@ rails follow sync e2e spec: 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: "2" + 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/Dockerfile b/Dockerfile index 7f0d26b..e7c10d1 100644 --- a/Dockerfile +++ b/Dockerfile @@ -81,7 +81,7 @@ 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 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/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/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/docker-compose.blackbox-follow.yml b/docker-compose.blackbox-follow.yml index a192dc1..25e946c 100644 --- a/docker-compose.blackbox-follow.yml +++ b/docker-compose.blackbox-follow.yml @@ -37,6 +37,12 @@ services: source-postgres: condition: service_healthy environment: + RAILS_ENV: ${TURNIERE_BLACKBOX_RAILS_ENV:-production} + WEB_CONCURRENCY: ${TURNIERE_BLACKBOX_WEB_CONCURRENCY:-2} + 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} @@ -59,6 +65,12 @@ services: follower-postgres: condition: service_healthy environment: + RAILS_ENV: ${TURNIERE_BLACKBOX_RAILS_ENV:-production} + WEB_CONCURRENCY: ${TURNIERE_BLACKBOX_WEB_CONCURRENCY:-2} + 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} diff --git a/docker-compose.blackbox.yml b/docker-compose.blackbox.yml index 15e5d3b..28a2244 100644 --- a/docker-compose.blackbox.yml +++ b/docker-compose.blackbox.yml @@ -19,6 +19,12 @@ services: postgres: condition: service_healthy environment: + RAILS_ENV: ${TURNIERE_BLACKBOX_RAILS_ENV:-production} + WEB_CONCURRENCY: ${TURNIERE_BLACKBOX_WEB_CONCURRENCY:-2} + 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} 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/tasks.py b/tasks.py index c1f323a..2e5efe6 100644 --- a/tasks.py +++ b/tasks.py @@ -470,12 +470,19 @@ 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), @@ -484,13 +491,20 @@ def _shared_production_env(): 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, @@ -499,6 +513,10 @@ def _production_env_for(db_host, db_name): 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"), ) @@ -1094,6 +1112,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, From 2c16a9bad141a01019a795ed7cb41933756ca881 Mon Sep 17 00:00:00 2001 From: Malaber Date: Thu, 30 Apr 2026 09:15:05 +0200 Subject: [PATCH 21/36] ci: run blackbox checks single-worker --- .gitlab-ci.yml | 8 ++++---- docker-compose.blackbox-follow.yml | 4 ++-- docker-compose.blackbox.yml | 2 +- 3 files changed, 7 insertions(+), 7 deletions(-) diff --git a/.gitlab-ci.yml b/.gitlab-ci.yml index 7c37abf..4db6544 100644 --- a/.gitlab-ci.yml +++ b/.gitlab-ci.yml @@ -57,7 +57,7 @@ rails e2e spec: alias: app variables: TURNIERE_DISABLE_EMAIL_DELIVERY: "1" - WEB_CONCURRENCY: "2" + WEB_CONCURRENCY: "1" RAILS_MAX_THREADS: "5" RAILS_DB_POOL: "5" SIDEKIQ_CONCURRENCY: "5" @@ -115,7 +115,7 @@ rails load smoke: alias: app variables: TURNIERE_DISABLE_EMAIL_DELIVERY: "1" - WEB_CONCURRENCY: "2" + WEB_CONCURRENCY: "1" RAILS_MAX_THREADS: "5" RAILS_DB_POOL: "5" SIDEKIQ_CONCURRENCY: "5" @@ -200,7 +200,7 @@ rails follow sync e2e spec: 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: "2" + WEB_CONCURRENCY: "1" RAILS_MAX_THREADS: "5" RAILS_DB_POOL: "5" SIDEKIQ_CONCURRENCY: "5" @@ -227,7 +227,7 @@ rails follow sync e2e spec: 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: "2" + WEB_CONCURRENCY: "1" RAILS_MAX_THREADS: "5" RAILS_DB_POOL: "5" SIDEKIQ_CONCURRENCY: "5" diff --git a/docker-compose.blackbox-follow.yml b/docker-compose.blackbox-follow.yml index 25e946c..4a2d10e 100644 --- a/docker-compose.blackbox-follow.yml +++ b/docker-compose.blackbox-follow.yml @@ -38,7 +38,7 @@ services: condition: service_healthy environment: RAILS_ENV: ${TURNIERE_BLACKBOX_RAILS_ENV:-production} - WEB_CONCURRENCY: ${TURNIERE_BLACKBOX_WEB_CONCURRENCY:-2} + 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} @@ -66,7 +66,7 @@ services: condition: service_healthy environment: RAILS_ENV: ${TURNIERE_BLACKBOX_RAILS_ENV:-production} - WEB_CONCURRENCY: ${TURNIERE_BLACKBOX_WEB_CONCURRENCY:-2} + 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} diff --git a/docker-compose.blackbox.yml b/docker-compose.blackbox.yml index 28a2244..2762984 100644 --- a/docker-compose.blackbox.yml +++ b/docker-compose.blackbox.yml @@ -20,7 +20,7 @@ services: condition: service_healthy environment: RAILS_ENV: ${TURNIERE_BLACKBOX_RAILS_ENV:-production} - WEB_CONCURRENCY: ${TURNIERE_BLACKBOX_WEB_CONCURRENCY:-2} + 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} From 2b63e61475597ee728e5f3674344b51e87856d39 Mon Sep 17 00:00:00 2001 From: Malaber Date: Thu, 30 Apr 2026 09:32:37 +0200 Subject: [PATCH 22/36] fix(sync): protect follower sync tokens Store read-only receiver tokens as keyed digests and expose only sync_auth_configured in owner responses. Keep leader push tokens write-only in API responses because outbound sync still needs to send them. Refs TUR-84 --- app/controllers/tournaments_controller.rb | 4 +- app/models/tournament.rb | 43 ++++++++++++++++-- app/serializers/tournament_serializer.rb | 5 +++ app/services/tournament_sync_schema.rb | 1 + ...d_sync_auth_token_digest_to_tournaments.rb | 36 +++++++++++++++ db/schema.rb | 5 ++- doc/leader_follower.md | 7 +-- .../tournaments_controller_spec.rb | 15 +++++++ spec/e2e/http/tournament_sync_test_spec.rb | 45 +++++++++++++++++++ spec/models/tournament_spec.rb | 34 ++++++++++++++ 10 files changed, 183 insertions(+), 12 deletions(-) create mode 100644 db/migrate/20260430120000_add_sync_auth_token_digest_to_tournaments.rb diff --git a/app/controllers/tournaments_controller.rb b/app/controllers/tournaments_controller.rb index 11cf0f2..6c08577 100644 --- a/app/controllers/tournaments_controller.rb +++ b/app/controllers/tournaments_controller.rb @@ -319,9 +319,7 @@ class TournamentsController < ApplicationController 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 diff --git a/app/models/tournament.rb b/app/models/tournament.rb index 26653ee..0daf7ae 100644 --- a/app/models/tournament.rb +++ b/app/models/tournament.rb @@ -1,6 +1,7 @@ # frozen_string_literal: true require 'securerandom' +require 'openssl' class Tournament < ApplicationRecord TIMER_MODES = %w[countdown countup].freeze @@ -26,6 +27,7 @@ class Tournament < ApplicationRecord after_initialize :generate_code after_create_commit :ensure_default_beamer! before_validation :normalize_timer_reason + 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? @@ -50,7 +52,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 @@ -80,15 +102,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! @@ -100,11 +122,24 @@ 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 diff --git a/app/serializers/tournament_serializer.rb b/app/serializers/tournament_serializer.rb index 2691690..8777d91 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 @@ -41,4 +42,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/services/tournament_sync_schema.rb b/app/services/tournament_sync_schema.rb index d698bf5..ee0a9ac 100644 --- a/app/services/tournament_sync_schema.rb +++ b/app/services/tournament_sync_schema.rb @@ -24,6 +24,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 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..9e514f6 --- /dev/null +++ b/db/migrate/20260430120000_add_sync_auth_token_digest_to_tournaments.rb @@ -0,0 +1,36 @@ +# frozen_string_literal: true + +require 'openssl' + +class AddSyncAuthTokenDigestToTournaments < ActiveRecord::Migration[7.0] + class MigrationTournament < ActiveRecord::Base + self.table_name = 'tournaments' + end + + def up + unless column_exists?(:tournaments, :sync_auth_token_digest) + add_column :tournaments, :sync_auth_token_digest, :string + end + + 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/schema.rb b/db/schema.rb index 85ae707..262dbe1 100644 --- a/db/schema.rb +++ b/db/schema.rb @@ -10,7 +10,7 @@ # # It's strongly recommended that you check this file into your version control system. -ActiveRecord::Schema[8.1].define(version: 2026_04_28_120000) do +ActiveRecord::Schema[8.1].define(version: 2026_04_30_120000) do create_table "beamers", force: :cascade do |t| t.json "config", default: {}, null: false t.datetime "created_at", null: false @@ -158,15 +158,16 @@ ActiveRecord::Schema[8.1].define(version: 2026_04_28_120000) do t.boolean "public", default: true t.boolean "read_only_mode", default: false, null: false t.string "sync_auth_token" + 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.datetime "timestamp" 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.index ["code"], name: "index_tournaments_on_code", unique: true 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/spec/controllers/tournaments_controller_spec.rb b/spec/controllers/tournaments_controller_spec.rb index 89c32e5..4f396b3 100644 --- a/spec/controllers/tournaments_controller_spec.rb +++ b/spec/controllers/tournaments_controller_spec.rb @@ -150,9 +150,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 @@ -389,8 +391,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 @@ -602,6 +608,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 @@ -618,6 +627,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 @@ -818,6 +828,8 @@ RSpec.describe TournamentsController, type: :controller do expect(@tournament.sync_source_tournament_id).to eq(123) expect(@tournament.name).to eq('Synced Tournament') 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 @@ -857,6 +869,9 @@ 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 diff --git a/spec/e2e/http/tournament_sync_test_spec.rb b/spec/e2e/http/tournament_sync_test_spec.rb index 08cb789..a1d26f9 100644 --- a/spec/e2e/http/tournament_sync_test_spec.rb +++ b/spec/e2e/http/tournament_sync_test_spec.rb @@ -44,6 +44,8 @@ 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)) @@ -54,6 +56,41 @@ RSpec.describe 'Tournament sync test HTTP E2E' do .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( @@ -170,6 +207,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 } 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 From 21e1463b64d4a48d1970e3e7845db788e90156eb Mon Sep 17 00:00:00 2001 From: Malaber Date: Thu, 30 Apr 2026 10:04:56 +0200 Subject: [PATCH 23/36] feat: add tournament transaction log --- app/controllers/match_scores_controller.rb | 62 +++- app/controllers/matches_controller.rb | 138 +++++-- app/controllers/stages_controller.rb | 152 ++++++-- ...ment_transaction_log_entries_controller.rb | 18 + app/controllers/tournaments_controller.rb | 31 +- app/models/tournament.rb | 1 + .../tournament_transaction_log_entry.rb | 10 + ...nament_transaction_log_entry_serializer.rb | 18 + .../tournament_group_match_starter.rb | 8 +- app/services/tournament_transaction_logger.rb | 345 ++++++++++++++++++ config/routes.rb | 1 + ...eate_tournament_transaction_log_entries.rb | 21 ++ db/schema.rb | 20 +- e2e/lib/scenario_runner.rb | 82 +++++ .../match_scores_controller_spec.rb | 17 + spec/controllers/matches_controller_spec.rb | 19 + spec/controllers/stages_controller_spec.rb | 14 + ...transaction_log_entries_controller_spec.rb | 50 +++ spec/e2e/http/tournament_lifecycle_spec.rb | 17 + spec/routing/tournaments_routing_spec.rb | 4 + .../tournament_transaction_logger_spec.rb | 31 ++ 21 files changed, 967 insertions(+), 92 deletions(-) create mode 100644 app/controllers/tournament_transaction_log_entries_controller.rb create mode 100644 app/models/tournament_transaction_log_entry.rb create mode 100644 app/serializers/tournament_transaction_log_entry_serializer.rb create mode 100644 app/services/tournament_transaction_logger.rb create mode 100644 db/migrate/20260430120000_create_tournament_transaction_log_entries.rb create mode 100644 spec/controllers/tournament_transaction_log_entries_controller_spec.rb create mode 100644 spec/services/tournament_transaction_logger_spec.rb diff --git a/app/controllers/match_scores_controller.rb b/app/controllers/match_scores_controller.rb index 502da1f..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_content + 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 8a83897..42477ba 100644 --- a/app/controllers/matches_controller.rb +++ b/app/controllers/matches_controller.rb @@ -23,37 +23,41 @@ 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_content - 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_content + 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' - handle_match_stop if new_state == 'not_started' - update_group_scores if @match.group_match? && %w[in_progress not_started].include?(new_state) - - render json: @match - synced_tournament = @match.tournament - else - render json: @match.errors, status: :unprocessable_content - 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? @@ -92,17 +96,21 @@ class MatchesController < ApplicationController unless UpdateGroupsGroupScoresAndSave.call(group: group).success? logger.warn "Updating groups group score failed for #{group}" end + @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_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_content @@ -117,27 +125,62 @@ class MatchesController < ApplicationController def update_group_scores group = @match.group - return if UpdateGroupsGroupScoresAndSave.call(group: group).success? + 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 validate_params - case match_params['state'] - when 'in_progress' - render json: { error: 'Match can\'t start in this state' }, status: :unprocessable_content \ - unless @match.not_started? - when 'finished' - render json: { error: 'Match can\'t finish in this state' }, status: :unprocessable_content \ - unless @match.in_progress? - when 'not_started' - render json: { error: 'Match can\'t stop in this state' }, status: :unprocessable_content \ - unless @match.in_progress? + 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) + render json: @match + tournament else - render json: { error: 'Invalid target state' }, status: :unprocessable_content + render json: @match.errors, status: :unprocessable_content + raise ActiveRecord::Rollback end end + def validate_transition_inside_transaction(new_state) + 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 + 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 @@ -186,6 +229,27 @@ class MatchesController < ApplicationController 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/stages_controller.rb b/app/controllers/stages_controller.rb index 972b159..194bbc0 100644 --- a/app/controllers/stages_controller.rb +++ b/app/controllers/stages_controller.rb @@ -13,43 +13,81 @@ 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_content - 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_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? - else - render json: { - error: 'The state attribute may only be changed to finished' - }, status: :unprocessable_content - 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_content + def finish_group_stage + synced_tournament = nil + @playoff_start_failure = nil + @transaction_log_advancing_teams = [] + + Stage.transaction do + synced_tournament = finish_group_stage_inside_transaction + end + record_playoff_start_failure if @playoff_start_failure + push_sync_if_needed!(synced_tournament) unless synced_tournament.nil? + TournamentLiveBroadcast.call(synced_tournament.id) unless synced_tournament.nil? + end + + def finish_group_stage_inside_transaction + TournamentTransactionLogger.lock_tournament!(@stage.tournament) + @stage.reload + return unless stage_running_for_playoff_start? + return unless group_stage_over_for_playoff_start? + return unless recalculate_group_scores_for_playoff_start + return unless no_blocking_ties_for_playoff_start? + + start_playoffs_inside_transaction + end + + def start_playoffs_inside_transaction + @transaction_log_advancing_teams = GroupStageService.get_advancing_teams(@stage) + if @stage.update(stage_params) && + AddPlayoffsToTournamentAndSave.call(tournament: @stage.tournament, + teams: @transaction_log_advancing_teams).success? + record_playoffs_started + render json: @stage + return @stage.tournament + elsif @stage.errors.any? + render json: @stage.errors, status: :unprocessable_content raise ActiveRecord::Rollback end + @playoff_start_failure = { reason: 'generation_failed', blocking_ties: [] } + render json: { error: 'Generating group stage failed' }, status: :unprocessable_content + raise ActiveRecord::Rollback + end + + def render_invalid_stage_state + render json: { + error: 'The state attribute may only be changed to finished' + }, status: :unprocessable_content + end + + def render_stage_not_running + render json: { error: 'Only running group stages can be finished' }, status: :unprocessable_content + end + + def stage_running_for_playoff_start? + return true if @stage.in_progress? + + render_stage_not_running + false + end + + def group_stage_over_for_playoff_start? + return true if @stage.over? + + render_playoff_start_failure(reason: 'unfinished_matches') + false + end + + def recalculate_group_scores_for_playoff_start @stage.groups.each do |group| next if UpdateGroupsGroupScoresAndSave.call(group: group).success? @@ -57,20 +95,56 @@ class StagesController < ApplicationController raise ActiveRecord::Rollback end + true + end + + def no_blocking_ties_for_playoff_start? blocking_ties = GroupStageService.blocking_ties_for(@stage) - if blocking_ties.any? - render json: { - error: 'Playoffs cannot be generated yet because a decider match is required for an advancing place', - blocking_ties: blocking_ties - }, status: :unprocessable_content - raise ActiveRecord::Rollback + 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 - return if AddPlayoffsToTournamentAndSave.call(tournament: @stage.tournament, - teams: GroupStageService.get_advancing_teams(@stage)).success? + { + error: 'Playoffs cannot be generated yet because a decider match is required for an advancing place', + blocking_ties: blocking_ties + } + end - render json: { error: 'Generating group stage failed' }, status: :unprocessable_content - raise ActiveRecord::Rollback + 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 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 11cf0f2..c1564bc 100644 --- a/app/controllers/tournaments_controller.rb +++ b/app/controllers/tournaments_controller.rb @@ -65,6 +65,7 @@ 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_content @@ -95,6 +96,7 @@ class TournamentsController < ApplicationController 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_content @@ -135,12 +137,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_content + 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 @@ -170,7 +189,7 @@ class TournamentsController < ApplicationController # PATCH /tournaments/:id/start_next_group_matches def start_next_group_matches - started_matches = TournamentGroupMatchStarter.start_next!(@tournament, logger:) + started_matches = TournamentGroupMatchStarter.start_next!(@tournament, logger:, user: current_user) if started_matches.any? push_sync_if_needed!(@tournament) TournamentLiveBroadcast.call(@tournament.id) diff --git a/app/models/tournament.rb b/app/models/tournament.rb index 26653ee..1585430 100644 --- a/app/models/tournament.rb +++ b/app/models/tournament.rb @@ -12,6 +12,7 @@ class Tournament < ApplicationRecord 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 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/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/tournament_group_match_starter.rb b/app/services/tournament_group_match_starter.rb index a6575c8..f11db45 100644 --- a/app/services/tournament_group_match_starter.rb +++ b/app/services/tournament_group_match_starter.rb @@ -10,13 +10,19 @@ class TournamentGroupMatchStarter selected_lowest_matches(group_matches, lowest_position) end - def start_next!(tournament, logger: Rails.logger) + 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 diff --git a/app/services/tournament_transaction_logger.rb b/app/services/tournament_transaction_logger.rb new file mode 100644 index 0000000..4111c6a --- /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? + + unless ActiveRecord::Base.connection.transaction_open? + raise 'Tournament transaction log writes must happen inside a transaction' + end + + tournament.lock! + 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/routes.rb b/config/routes.rb index ce2aeb8..3176d07 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -32,6 +32,7 @@ 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 diff --git a/db/migrate/20260430120000_create_tournament_transaction_log_entries.rb b/db/migrate/20260430120000_create_tournament_transaction_log_entries.rb new file mode 100644 index 0000000..ed194c8 --- /dev/null +++ b/db/migrate/20260430120000_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, + [:tournament_id, :sequence], + unique: true, + name: 'index_tournament_log_entries_on_tournament_and_sequence' + end +end diff --git a/db/schema.rb b/db/schema.rb index 85ae707..e8098bc 100644 --- a/db/schema.rb +++ b/db/schema.rb @@ -10,7 +10,7 @@ # # It's strongly recommended that you check this file into your version control system. -ActiveRecord::Schema[8.1].define(version: 2026_04_28_120000) do +ActiveRecord::Schema[8.1].define(version: 2026_04_30_120000) do create_table "beamers", force: :cascade do |t| t.json "config", default: {}, null: false t.datetime "created_at", null: false @@ -147,6 +147,20 @@ ActiveRecord::Schema[8.1].define(version: 2026_04_28_120000) do 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 "code", null: false t.datetime "created_at", precision: nil, null: false @@ -164,9 +178,9 @@ ActiveRecord::Schema[8.1].define(version: 2026_04_28_120000) do t.integer "sync_source_tournament_id" t.string "sync_target_url" t.string "timer_mode" - t.datetime "timestamp" 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.index ["code"], name: "index_tournaments_on_code", unique: true @@ -220,5 +234,7 @@ ActiveRecord::Schema[8.1].define(version: 2026_04_28_120000) do add_foreign_key "team_action_lists", "tournaments", on_delete: :cascade add_foreign_key "teams", "tournaments", on_delete: :cascade add_foreign_key "tournament_sync_queue_entries", "tournaments", on_delete: :cascade + add_foreign_key "tournament_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/e2e/lib/scenario_runner.rb b/e2e/lib/scenario_runner.rb index f3d3eb5..dace468 100644 --- a/e2e/lib/scenario_runner.rb +++ b/e2e/lib/scenario_runner.rb @@ -148,6 +148,58 @@ module TurniereE2E 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!( @@ -339,6 +391,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}") diff --git a/spec/controllers/match_scores_controller_spec.rb b/spec/controllers/match_scores_controller_spec.rb index 4a23db8..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 diff --git a/spec/controllers/matches_controller_spec.rb b/spec/controllers/matches_controller_spec.rb index ed1a5a0..60c92a2 100644 --- a/spec/controllers/matches_controller_spec.rb +++ b/spec/controllers/matches_controller_spec.rb @@ -101,6 +101,8 @@ 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: team_ids } end.to change { group.matches.reload.count }.by(1) @@ -110,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 @@ -233,6 +237,21 @@ RSpec.describe MatchesController, type: :controller do expect(body[:state]).to eq(valid_update[:state]) 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| diff --git a/spec/controllers/stages_controller_spec.rb b/spec/controllers/stages_controller_spec.rb index bd7ba8c..d447eec 100644 --- a/spec/controllers/stages_controller_spec.rb +++ b/spec/controllers/stages_controller_spec.rb @@ -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 @@ -206,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 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/e2e/http/tournament_lifecycle_spec.rb b/spec/e2e/http/tournament_lifecycle_spec.rb index 36f8ee2..437c063 100644 --- a/spec/e2e/http/tournament_lifecycle_spec.rb +++ b/spec/e2e/http/tournament_lifecycle_spec.rb @@ -129,4 +129,21 @@ RSpec.describe 'Tournament lifecycle HTTP E2E' do 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/routing/tournaments_routing_spec.rb b/spec/routing/tournaments_routing_spec.rb index 3cb398a..0c185eb 100644 --- a/spec/routing/tournaments_routing_spec.rb +++ b/spec/routing/tournaments_routing_spec.rb @@ -41,5 +41,9 @@ RSpec.describe TournamentsController, type: :routing do 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/tournament_transaction_logger_spec.rb b/spec/services/tournament_transaction_logger_spec.rb new file mode 100644 index 0000000..402a71f --- /dev/null +++ b/spec/services/tournament_transaction_logger_spec.rb @@ -0,0 +1,31 @@ +# 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 + end +end From 8f965ad99e6706e48a11eb45917f4b4cb94d4d75 Mon Sep 17 00:00:00 2001 From: Malaber Date: Thu, 30 Apr 2026 12:08:00 +0200 Subject: [PATCH 24/36] fix: preserve timer broadcast after logging --- app/services/tournament_transaction_logger.rb | 4 ++- .../tournament_transaction_logger_spec.rb | 27 +++++++++++++++++++ 2 files changed, 30 insertions(+), 1 deletion(-) diff --git a/app/services/tournament_transaction_logger.rb b/app/services/tournament_transaction_logger.rb index 4111c6a..90b0255 100644 --- a/app/services/tournament_transaction_logger.rb +++ b/app/services/tournament_transaction_logger.rb @@ -9,7 +9,9 @@ class TournamentTransactionLogger raise 'Tournament transaction log writes must happen inside a transaction' end - tournament.lock! + # 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: {}) diff --git a/spec/services/tournament_transaction_logger_spec.rb b/spec/services/tournament_transaction_logger_spec.rb index 402a71f..78e2851 100644 --- a/spec/services/tournament_transaction_logger_spec.rb +++ b/spec/services/tournament_transaction_logger_spec.rb @@ -27,5 +27,32 @@ RSpec.describe TournamentTransactionLogger do 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 From 431f0a66403b33b95b5a7ad246f47c7b5c98d277 Mon Sep 17 00:00:00 2001 From: Malaber Date: Thu, 30 Apr 2026 14:09:45 +0200 Subject: [PATCH 25/36] docs: document transaction log API --- ...ate_tournament_transaction_log_entries.rb} | 0 db/schema.rb | 2 +- doc/tournament_transaction_log_api.md | 281 ++++++++++++++++++ 3 files changed, 282 insertions(+), 1 deletion(-) rename db/migrate/{20260430120000_create_tournament_transaction_log_entries.rb => 20260430121000_create_tournament_transaction_log_entries.rb} (100%) create mode 100644 doc/tournament_transaction_log_api.md diff --git a/db/migrate/20260430120000_create_tournament_transaction_log_entries.rb b/db/migrate/20260430121000_create_tournament_transaction_log_entries.rb similarity index 100% rename from db/migrate/20260430120000_create_tournament_transaction_log_entries.rb rename to db/migrate/20260430121000_create_tournament_transaction_log_entries.rb diff --git a/db/schema.rb b/db/schema.rb index 8471d00..773e654 100644 --- a/db/schema.rb +++ b/db/schema.rb @@ -10,7 +10,7 @@ # # It's strongly recommended that you check this file into your version control system. -ActiveRecord::Schema[8.1].define(version: 2026_04_30_120000) do +ActiveRecord::Schema[8.1].define(version: 2026_04_30_121000) do create_table "beamers", force: :cascade do |t| t.json "config", default: {}, null: false t.datetime "created_at", null: false 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. From 7fae7e2d950dd8af4049ff3132c3fb155bb01d28 Mon Sep 17 00:00:00 2001 From: Malaber Date: Mon, 4 May 2026 14:40:04 +0200 Subject: [PATCH 26/36] feat(timer): update reason without restart --- app/controllers/tournaments_controller.rb | 34 ++++++++++++-- doc/timer_api.md | 36 ++++++++++++++ .../tournaments_controller_spec.rb | 47 +++++++++++++++++++ spec/e2e/http/api_surface_spec.rb | 17 +++++++ ...nament_timer_follow_sync_websocket_spec.rb | 37 ++++++++++++++- .../http/tournament_timer_websocket_spec.rb | 35 +++++++++++++- tasks.py | 11 ++++- 7 files changed, 208 insertions(+), 9 deletions(-) diff --git a/app/controllers/tournaments_controller.rb b/app/controllers/tournaments_controller.rb index f878c77..d5dd499 100644 --- a/app/controllers/tournaments_controller.rb +++ b/app/controllers/tournaments_controller.rb @@ -205,12 +205,13 @@ class TournamentsController < ApplicationController 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) @@ -371,6 +372,9 @@ 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] @@ -384,7 +388,12 @@ class TournamentsController < ApplicationController return render_timer_validation_error('Invalid timer_mode') end - timer_reason_error = validate_timer_reason_params(timer_reason, timer_reason_text) + 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 # throw error if both timestamp and timestamp_seconds are present @@ -428,6 +437,8 @@ class TournamentsController < ApplicationController params[:timestamp] = Time.zone.now elsif timer_mode.present? return render_timer_validation_error('timer_mode requires timestamp, timestamp_seconds, or clear_timer') + elsif timer_reason_update_requested + return render_timer_validation_error('timer_reason requires active timer') unless timer_active? else return render_timer_validation_error('timestamp, timestamp_seconds, or clear_timer is required') end @@ -456,8 +467,17 @@ class TournamentsController < ApplicationController params[:timer_reason_text] = nil end - def validate_timer_reason_params(timer_reason, timer_reason_text) + def validate_timer_reason_params( + timer_reason, + timer_reason_text, + timer_reason_param_present, + timer_reason_text_param_present + ) + if timer_reason_text_param_present && !timer_reason_param_present + return 'timer_reason is required when timer_reason_text is provided' + end 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) @@ -470,6 +490,10 @@ class TournamentsController < ApplicationController '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 diff --git a/doc/timer_api.md b/doc/timer_api.md index f1d0622..b60447f 100644 --- a/doc/timer_api.md +++ b/doc/timer_api.md @@ -185,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: @@ -215,6 +248,8 @@ Backend rejects: - `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` @@ -338,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) diff --git a/spec/controllers/tournaments_controller_spec.rb b/spec/controllers/tournaments_controller_spec.rb index 4f396b3..9f76c7e 100644 --- a/spec/controllers/tournaments_controller_spec.rb +++ b/spec/controllers/tournaments_controller_spec.rb @@ -307,6 +307,53 @@ RSpec.describe TournamentsController, type: :controller do 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 } diff --git a/spec/e2e/http/api_surface_spec.rb b/spec/e2e/http/api_surface_spec.rb index f8600d7..45a1531 100644 --- a/spec/e2e/http/api_surface_spec.rb +++ b/spec/e2e/http/api_surface_spec.rb @@ -139,6 +139,13 @@ RSpec.describe 'Backend API surface HTTP E2E' do timer_reason: 'tournament_start' }) timer_show = anonymous_client.get("/tournaments/#{tournament.fetch(:id)}/timer") + 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', @@ -194,6 +201,16 @@ RSpec.describe 'Backend API surface HTTP E2E' do 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') 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 4112807..5b101d6 100644 --- a/spec/e2e/http/tournament_timer_follow_sync_websocket_spec.rb +++ b/spec/e2e/http/tournament_timer_follow_sync_websocket_spec.rb @@ -55,6 +55,35 @@ RSpec.describe 'Tournament timer follower sync websocket HTTP E2E' do expect(countdown_payload.fetch('timer_reason')).to eq('match_end') expect(countdown_payload.fetch('timer_reason_text')).to be_nil + 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', @@ -121,10 +150,16 @@ RSpec.describe 'Tournament timer follower sync websocket HTTP E2E' do 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 payload.fetch('timer_mode') == timer_mode + return payload if yield(payload) raise "follower timer websocket payload not received within #{timeout}s" if Time.now >= deadline end diff --git a/spec/e2e/http/tournament_timer_websocket_spec.rb b/spec/e2e/http/tournament_timer_websocket_spec.rb index 73913bb..69fead4 100644 --- a/spec/e2e/http/tournament_timer_websocket_spec.rb +++ b/spec/e2e/http/tournament_timer_websocket_spec.rb @@ -45,6 +45,33 @@ RSpec.describe 'Tournament timer websocket HTTP E2E' do expect(countdown_payload.fetch('timer_reason')).to eq('playoff_start') expect(countdown_payload.fetch('timer_reason_text')).to be_nil + 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', @@ -90,10 +117,16 @@ RSpec.describe 'Tournament timer websocket HTTP E2E' do 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 payload.fetch('timer_mode') == timer_mode + return payload if yield(payload) raise "timer websocket payload not received within #{timeout}s" if Time.now >= deadline end diff --git a/tasks.py b/tasks.py index 2e5efe6..4ce0978 100644 --- a/tasks.py +++ b/tasks.py @@ -633,10 +633,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(GIT_COMMIT_SHA=_git_commit_sha()), + _server_env("test"), ) @@ -704,7 +711,7 @@ 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}", - env=_env(GIT_COMMIT_SHA=_git_commit_sha()), + env=_server_env(env), pty=True, ) From 9b10cc12d2c73e32c8f22fda1c0cb9e54ccd6f17 Mon Sep 17 00:00:00 2001 From: Malaber Date: Mon, 4 May 2026 14:43:20 +0200 Subject: [PATCH 27/36] feat(tournaments): add metadata fields Add typed tournament metadata to API payloads and follower sync. --- app/controllers/tournaments_controller.rb | 1 + app/models/tournament.rb | 18 +++++ .../simple_tournament_serializer.rb | 10 ++- app/services/tournament_snapshot_builder.rb | 2 + app/services/tournament_snapshot_importer.rb | 6 ++ app/services/tournament_sync_schema.rb | 6 ++ ...60504100000_add_metadata_to_tournaments.rb | 14 ++++ db/schema.rb | 8 +- .../tournaments_controller_spec.rb | 79 +++++++++++++++++++ spec/e2e/http/api_surface_spec.rb | 30 +++++++ spec/e2e/http/tournament_follow_sync_spec.rb | 30 +++++++ spec/e2e/http/tournament_sync_test_spec.rb | 14 +++- .../tournament_snapshot_importer_spec.rb | 18 +++++ 13 files changed, 233 insertions(+), 3 deletions(-) create mode 100644 db/migrate/20260504100000_add_metadata_to_tournaments.rb diff --git a/app/controllers/tournaments_controller.rb b/app/controllers/tournaments_controller.rb index f878c77..5d05a5f 100644 --- a/app/controllers/tournaments_controller.rb +++ b/app/controllers/tournaments_controller.rb @@ -285,6 +285,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, :organizer_name, :contact_email, :website_url, :read_only_mode, :sync_target_url, :sync_auth_token ).permit! end diff --git a/app/models/tournament.rb b/app/models/tournament.rb index 4063d27..2b2f2d8 100644 --- a/app/models/tournament.rb +++ b/app/models/tournament.rb @@ -6,6 +6,7 @@ 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 organizer_name contact_email website_url].freeze belongs_to :user belongs_to :owner, class_name: 'User', foreign_key: :user_id @@ -20,14 +21,18 @@ class Tournament < ApplicationRecord 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 + validates :contact_email, format: URI::MailTo::EMAIL_REGEXP, allow_blank: true validate :sync_configuration_is_complete validate :playoff_teams_amount_is_positive_power_of_two 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? @@ -154,6 +159,19 @@ class Tournament < ApplicationRecord 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? || diff --git a/app/serializers/simple_tournament_serializer.rb b/app/serializers/simple_tournament_serializer.rb index 0d88639..6bb26e4 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, :organizer_name, :contact_email, :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/services/tournament_snapshot_builder.rb b/app/services/tournament_snapshot_builder.rb index 0975a78..148c76e 100644 --- a/app/services/tournament_snapshot_builder.rb +++ b/app/services/tournament_snapshot_builder.rb @@ -22,6 +22,8 @@ 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 diff --git a/app/services/tournament_snapshot_importer.rb b/app/services/tournament_snapshot_importer.rb index c2868da..0b443f3 100644 --- a/app/services/tournament_snapshot_importer.rb +++ b/app/services/tournament_snapshot_importer.rb @@ -6,6 +6,10 @@ class TournamentSnapshotImporter code description public + location + organizer_name + contact_email + website_url playoff_teams_amount instant_finalists_amount intermediate_round_participants_amount @@ -67,6 +71,8 @@ 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] diff --git a/app/services/tournament_sync_schema.rb b/app/services/tournament_sync_schema.rb index ee0a9ac..7c9f436 100644 --- a/app/services/tournament_sync_schema.rb +++ b/app/services/tournament_sync_schema.rb @@ -9,6 +9,12 @@ class TournamentSyncSchema code description public + location + starts_at + ends_at + organizer_name + contact_email + website_url playoff_teams_amount instant_finalists_amount intermediate_round_participants_amount 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..5d10c4e --- /dev/null +++ b/db/migrate/20260504100000_add_metadata_to_tournaments.rb @@ -0,0 +1,14 @@ +# 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 :organizer_name + t.string :contact_email + t.string :website_url + end + end +end diff --git a/db/schema.rb b/db/schema.rb index 773e654..766e949 100644 --- a/db/schema.rb +++ b/db/schema.rb @@ -10,7 +10,7 @@ # # It's strongly recommended that you check this file into your version control system. -ActiveRecord::Schema[8.1].define(version: 2026_04_30_121000) do +ActiveRecord::Schema[8.1].define(version: 2026_05_04_100000) do create_table "beamers", force: :cascade do |t| t.json "config", default: {}, null: false t.datetime "created_at", null: false @@ -163,14 +163,19 @@ ActiveRecord::Schema[8.1].define(version: 2026_04_30_121000) do create_table "tournaments", force: :cascade do |t| t.string "code", null: false + t.string "contact_email" t.datetime "created_at", precision: nil, null: false t.string "description" + t.datetime "ends_at" t.integer "instant_finalists_amount", default: 0 t.integer "intermediate_round_participants_amount", default: 0 + t.string "location" t.string "name", null: false + t.string "organizer_name" t.integer "playoff_teams_amount", default: 0 t.boolean "public", default: true t.boolean "read_only_mode", default: false, null: false + t.datetime "starts_at" t.string "sync_auth_token" t.string "sync_auth_token_digest" t.datetime "sync_last_imported_snapshot_at" @@ -184,6 +189,7 @@ ActiveRecord::Schema[8.1].define(version: 2026_04_30_121000) do 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 diff --git a/spec/controllers/tournaments_controller_spec.rb b/spec/controllers/tournaments_controller_spec.rb index 4f396b3..fdbc7d2 100644 --- a/spec/controllers/tournaments_controller_spec.rb +++ b/spec/controllers/tournaments_controller_spec.rb @@ -105,6 +105,7 @@ RSpec.describe TournamentsController, type: :controller do expected_keys = %i[ id name code public description playoff_teams_amount instant_finalists_amount intermediate_round_participants_amount + location starts_at ends_at organizer_name contact_email 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) @@ -407,6 +408,29 @@ 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, + organizer_name: 'BPWSTR Crew', + contact_email: 'orga@example.com', + 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.organizer_name).to eq('BPWSTR Crew') + expect(tournament.contact_email).to eq('orga@example.com') + 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 @@ -587,6 +611,49 @@ 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, + organizer_name: 'Turniere Ops', + contact_email: 'ops@example.com', + 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.organizer_name).to eq('Turniere Ops') + expect(@tournament.contact_email).to eq('ops@example.com') + 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', + contact_email: 'not-an-email', + 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[:contact_email]).to include('is invalid') + 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) @@ -810,6 +877,12 @@ 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', + organizer_name: 'Synced Crew', + contact_email: 'sync@example.com', + website_url: 'https://example.com/synced', playoff_teams_amount: 4, instant_finalists_amount: 4, intermediate_round_participants_amount: 0, @@ -827,6 +900,12 @@ 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.organizer_name).to eq('Synced Crew') + expect(@tournament.contact_email).to eq('sync@example.com') + 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) diff --git a/spec/e2e/http/api_surface_spec.rb b/spec/e2e/http/api_surface_spec.rb index f8600d7..a92fbac 100644 --- a/spec/e2e/http/api_surface_spec.rb +++ b/spec/e2e/http/api_surface_spec.rb @@ -82,6 +82,12 @@ 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, :organizer_name)).to eq('Turniere E2E') + expect(full_show.dig(:json, :contact_email)).to eq('events@example.com') + 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') @@ -119,6 +125,12 @@ RSpec.describe 'Backend API surface HTTP E2E' do 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', + organizer_name: 'Updated E2E Org', + contact_email: 'updated-events@example.com', + website_url: 'https://example.com/updated-turniere-e2e', playoff_teams_amount: 8, instant_finalists_amount: 8, intermediate_round_participants_amount: 0 @@ -172,6 +184,12 @@ 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, :organizer_name)).to eq('Updated E2E Org') + expect(update_response.dig(:json, :contact_email)).to eq('updated-events@example.com') + 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) @@ -511,6 +529,12 @@ RSpec.describe 'Backend API surface HTTP E2E' do name: "#{name_prefix} #{unique_suffix}", description: 'HTTP API surface E2E group-stage tournament', public: public, + location: "Venue #{name_prefix}", + starts_at: '2030-06-01T10:00:00+02:00', + ends_at: '2030-06-01T18:00:00+02:00', + organizer_name: 'Turniere E2E', + contact_email: 'events@example.com', + website_url: 'https://example.com/turniere-e2e', group_stage: true, playoff_teams_amount: 8, teams: 4.times.flat_map do |group_index| @@ -567,6 +591,12 @@ RSpec.describe 'Backend API surface HTTP E2E' do name: "#{name_prefix} #{unique_suffix}", description: 'HTTP API surface E2E playoff tournament', public: public, + location: "Venue #{name_prefix}", + starts_at: '2030-06-01T10:00:00+02:00', + ends_at: '2030-06-01T18:00:00+02:00', + organizer_name: 'Turniere E2E', + contact_email: 'events@example.com', + website_url: 'https://example.com/turniere-e2e', teams: 4.times.map do |index| { name: "#{name_prefix.tr(' ', '')}-S#{index + 1}" } end diff --git a/spec/e2e/http/tournament_follow_sync_spec.rb b/spec/e2e/http/tournament_follow_sync_spec.rb index aa3f87c..7560aed 100644 --- a/spec/e2e/http/tournament_follow_sync_spec.rb +++ b/spec/e2e/http/tournament_follow_sync_spec.rb @@ -111,6 +111,12 @@ RSpec.describe 'Tournament follower sync HTTP E2E' do log_phase!('tighten playoff cutoff and rename teams') update_cutoff = source_owner_client.patch("/tournaments/#{source.fetch(:id)}", body: { + location: 'Source Updated Arena', + starts_at: '2030-07-01T10:00:00+02:00', + ends_at: '2030-07-01T18:00:00+02:00', + organizer_name: 'Updated Sync Crew', + contact_email: 'updated-sync@example.com', + website_url: 'https://example.com/updated-sync-source', playoff_teams_amount: 4, instant_finalists_amount: 4, intermediate_round_participants_amount: 0 @@ -233,6 +239,12 @@ RSpec.describe 'Tournament follower sync HTTP E2E' do name: "#{name_prefix} #{unique_suffix}", description: 'Follower sync source tournament', 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', + organizer_name: 'Sync Source Crew', + contact_email: 'sync-source@example.com', + website_url: 'https://example.com/sync-source', group_stage: true, playoff_teams_amount: 8, teams: 4.times.flat_map do |group_index| @@ -255,6 +267,12 @@ RSpec.describe 'Tournament follower sync HTTP E2E' do name: "#{name_prefix} #{unique_suffix}", description: 'Unrelated playoff tournament', 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', + organizer_name: 'Noise Crew', + contact_email: 'noise@example.com', + website_url: 'https://example.com/noise', teams: 4.times.map do |index| { name: "#{name_prefix.tr(' ', '')}-S#{index + 1}" } end @@ -270,6 +288,12 @@ RSpec.describe 'Tournament follower sync HTTP E2E' do name: "#{name_prefix} #{unique_suffix}", description: 'Follower tournament', 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', + organizer_name: 'Follower Crew', + contact_email: 'follower@example.com', + website_url: 'https://example.com/follower', read_only_mode: true, sync_auth_token: sync_token }) @@ -500,6 +524,12 @@ 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], + organizer_name: tournament[:organizer_name], + contact_email: tournament[:contact_email], + 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), diff --git a/spec/e2e/http/tournament_sync_test_spec.rb b/spec/e2e/http/tournament_sync_test_spec.rb index a1d26f9..8df3498 100644 --- a/spec/e2e/http/tournament_sync_test_spec.rb +++ b/spec/e2e/http/tournament_sync_test_spec.rb @@ -33,7 +33,13 @@ 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', + organizer_name: 'Sync Test Org', + contact_email: 'sync-test@example.com', + website_url: 'https://example.com/sync-test' }) expect(rename[:status]).to eq(200) @@ -52,6 +58,12 @@ RSpec.describe 'Tournament sync test HTTP E2E' do 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(:organizer_name)).to eq(after_leader.fetch(:organizer_name)) + expect(after_follower.fetch(:contact_email)).to eq(after_leader.fetch(:contact_email)) + 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 diff --git a/spec/services/tournament_snapshot_importer_spec.rb b/spec/services/tournament_snapshot_importer_spec.rb index b86e6fe..f9b563b 100644 --- a/spec/services/tournament_snapshot_importer_spec.rb +++ b/spec/services/tournament_snapshot_importer_spec.rb @@ -7,6 +7,12 @@ RSpec.describe TournamentSnapshotImporter 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), + organizer_name: 'Sync Ops', + contact_email: 'sync-ops@example.com', + website_url: 'https://example.com/sync-cup', timestamp: Time.utc(2026, 4, 24, 10, 15, 0), timer_mode: 'countdown', timer_reason: 'custom', @@ -28,6 +34,12 @@ RSpec.describe TournamentSnapshotImporter do 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.organizer_name).to eq(source.organizer_name) + expect(follower.contact_email).to eq(source.contact_email) + 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) @@ -197,6 +209,12 @@ RSpec.describe TournamentSnapshotImporter do { name: tournament.name, public: tournament.public, + location: tournament.location, + starts_at: tournament.starts_at&.iso8601, + ends_at: tournament.ends_at&.iso8601, + organizer_name: tournament.organizer_name, + contact_email: tournament.contact_email, + 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, From 995a2cc2503f7a08814c89a1151e191fbc75e949 Mon Sep 17 00:00:00 2001 From: Malaber Date: Mon, 4 May 2026 14:52:59 +0200 Subject: [PATCH 28/36] feat(accounts): add player team profiles Teams can link to player accounts by email while keeping profile tournament data public unless viewed by the account owner. --- app/controllers/accounts_controller.rb | 15 +++++ app/controllers/application_controller.rb | 7 +- app/controllers/teams_controller.rb | 51 ++++++++++++++- app/controllers/tournaments_controller.rb | 11 +++- app/models/team.rb | 3 +- app/models/user.rb | 32 ++++++++++ app/serializers/account_profile_serializer.rb | 60 +++++++++++++++++ app/serializers/account_summary_serializer.rb | 9 +++ app/serializers/team_serializer.rb | 6 +- app/serializers/tournament_serializer.rb | 1 + app/services/tournament_sync_schema.rb | 2 +- config/routes.rb | 1 + .../20260504120000_add_player_to_teams.rb | 7 ++ db/schema.rb | 5 +- spec/controllers/accounts_controller_spec.rb | 54 ++++++++++++++++ spec/controllers/teams_controller_spec.rb | 64 +++++++++++++++++++ .../tournaments_controller_spec.rb | 19 ++++++ spec/e2e/http/api_surface_spec.rb | 59 ++++++++++++++++- spec/models/team_spec.rb | 1 + spec/models/user_spec.rb | 2 + spec/routing/accounts_routing_spec.rb | 11 ++++ 21 files changed, 409 insertions(+), 11 deletions(-) create mode 100644 app/controllers/accounts_controller.rb create mode 100644 app/serializers/account_profile_serializer.rb create mode 100644 app/serializers/account_summary_serializer.rb create mode 100644 db/migrate/20260504120000_add_player_to_teams.rb create mode 100644 spec/controllers/accounts_controller_spec.rb create mode 100644 spec/routing/accounts_routing_spec.rb 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 97043de..a3451c5 100644 --- a/app/controllers/application_controller.rb +++ b/app/controllers/application_controller.rb @@ -9,6 +9,7 @@ class ApplicationController < ActionController::API 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 @@ -56,7 +57,7 @@ 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) @@ -67,4 +68,8 @@ class ApplicationController < ActionController::API 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/teams_controller.rb b/app/controllers/teams_controller.rb index d6e07c4..0a05825 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,7 +13,11 @@ class TeamsController < ApplicationController # PATCH/PUT /teams/1 def update - if @team.update(team_params) + @team.assign_attributes(team_params) + assign_player_from_params if player_assignment_requested? + return if performed? + + if @team.save push_sync_if_needed!(@team.tournament) render json: @team else @@ -31,6 +35,49 @@ class TeamsController < ApplicationController params.slice(:name).permit! end + def player_assignment_requested? + params.key?(:player_email) || params.key?(:player_id) + end + + def assign_player_from_params + unless tournament_owner? + render_forbidden_error + return + end + + @team.player = if params.key?(:player_email) + player_from_email + else + player_from_id + end + rescue ActiveRecord::RecordInvalid => e + render json: e.record.errors, status: :unprocessable_content + end + + def player_from_email + email = params[:player_email].to_s.strip + return nil if email.blank? + + User.find_or_create_player_by_email!(email) + end + + def player_from_id + player_id = params[:player_id].to_s.strip + return nil if player_id.blank? + + User.find(player_id) + end + + def require_team_update_actor! + return if tournament_owner? || @team.player == 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/tournaments_controller.rb b/app/controllers/tournaments_controller.rb index f878c77..ca202f5 100644 --- a/app/controllers/tournaments_controller.rb +++ b/app/controllers/tournaments_controller.rb @@ -236,10 +236,17 @@ class TournamentsController < ApplicationController if team[:id] Team.find team[:id] elsif team[:name] - Team.create name: team[:name] + Team.create name: team[:name], player: player_from_team_params(team) end end + def player_from_team_params(team) + player_email = team[:player_email].to_s.strip + return nil if player_email.blank? + + User.find_or_create_player_by_email!(player_email) + end + def set_tournament @tournament = Tournament.find(params[:id]) end @@ -249,7 +256,7 @@ class TournamentsController < ApplicationController @tournament = profiling.measure('load_tournament') do Tournament.includes( :user, - :teams, + { teams: :player }, stages: [ { matches: { match_scores: :team } }, { groups: [ diff --git a/app/models/team.rb b/app/models/team.rb index 4ad8508..6f39fd5 100644 --- a/app/models/team.rb +++ b/app/models/team.rb @@ -2,6 +2,7 @@ class Team < ApplicationRecord belongs_to :tournament, optional: true + belongs_to :player, class_name: 'User', optional: true, inverse_of: :player_teams has_many :group_scores, dependent: :destroy has_many :match_scores, dependent: :destroy has_many :bets, dependent: :destroy @@ -9,5 +10,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/user.rb b/app/models/user.rb index 6d2a109..a887116 100644 --- a/app/models/user.rb +++ b/app/models/user.rb @@ -11,5 +11,37 @@ class User < ApplicationRecord validates :username, presence: true, uniqueness: { case_sensitive: false } has_many :tournaments, dependent: :destroy + has_many :player_teams, class_name: 'Team', foreign_key: :player_id, inverse_of: :player, dependent: :nullify + 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/team_serializer.rb b/app/serializers/team_serializer.rb index cf33a86..eaa4fb5 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, :player + + def player + AccountSummarySerializer.new(object.player).as_json if object.player + end end diff --git a/app/serializers/tournament_serializer.rb b/app/serializers/tournament_serializer.rb index 8777d91..cb60514 100644 --- a/app/serializers/tournament_serializer.rb +++ b/app/serializers/tournament_serializer.rb @@ -34,6 +34,7 @@ class TournamentSerializer < SimpleTournamentSerializer { id: team.id, name: team.name, + player: team.player && AccountSummarySerializer.new(team.player).as_json, advancing_from_group_stage: adv_teams.include?(team) } end diff --git a/app/services/tournament_sync_schema.rb b/app/services/tournament_sync_schema.rb index ee0a9ac..a103b5c 100644 --- a/app/services/tournament_sync_schema.rb +++ b/app/services/tournament_sync_schema.rb @@ -33,7 +33,7 @@ class TournamentSyncSchema }.freeze, Team => { synced: %w[id name].freeze, - ignored: %w[tournament_id created_at updated_at sync_source_id].freeze + ignored: %w[tournament_id player_id created_at updated_at sync_source_id].freeze }.freeze, Beamer => { synced: %w[id name display_state is_default config].freeze, diff --git a/config/routes.rb b/config/routes.rb index 3176d07..be17e08 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -19,6 +19,7 @@ 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] diff --git a/db/migrate/20260504120000_add_player_to_teams.rb b/db/migrate/20260504120000_add_player_to_teams.rb new file mode 100644 index 0000000..b300983 --- /dev/null +++ b/db/migrate/20260504120000_add_player_to_teams.rb @@ -0,0 +1,7 @@ +# frozen_string_literal: true + +class AddPlayerToTeams < ActiveRecord::Migration[7.0] + def change + add_reference :teams, :player, type: :integer, foreign_key: { to_table: :users }, index: true + end +end diff --git a/db/schema.rb b/db/schema.rb index 773e654..67ad9a4 100644 --- a/db/schema.rb +++ b/db/schema.rb @@ -10,7 +10,7 @@ # # It's strongly recommended that you check this file into your version control system. -ActiveRecord::Schema[8.1].define(version: 2026_04_30_121000) do +ActiveRecord::Schema[8.1].define(version: 2026_05_04_120000) do create_table "beamers", force: :cascade do |t| t.json "config", default: {}, null: false t.datetime "created_at", null: false @@ -124,9 +124,11 @@ ActiveRecord::Schema[8.1].define(version: 2026_04_30_121000) do create_table "teams", force: :cascade do |t| t.datetime "created_at", precision: nil, null: false t.string "name" + t.integer "player_id" t.integer "sync_source_id" t.integer "tournament_id" t.datetime "updated_at", precision: nil, null: false + t.index ["player_id"], name: "index_teams_on_player_id" t.index ["tournament_id"], name: "index_teams_on_tournament_id" end @@ -234,6 +236,7 @@ ActiveRecord::Schema[8.1].define(version: 2026_04_30_121000) do add_foreign_key "team_action_items", "teams", on_delete: :cascade add_foreign_key "team_action_lists", "tournaments", on_delete: :cascade add_foreign_key "teams", "tournaments", on_delete: :cascade + add_foreign_key "teams", "users", column: "player_id" 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 diff --git a/spec/controllers/accounts_controller_spec.rb b/spec/controllers/accounts_controller_spec.rb new file mode 100644 index 0000000..e73d58a --- /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', player: account) } + let(:private_team) { create(:team, name: 'Private Team', player: 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/teams_controller_spec.rb b/spec/controllers/teams_controller_spec.rb index 8dc9eb1..778e551 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 summary when linked' do + player = create(:user, username: 'linked-player') + @team.update!(player: player) + + get :show, params: { id: @team.to_param } + + body = deserialize_response response + expect(body.dig(:player, :id)).to eq(player.id) + expect(body.dig(:player, :name)).to eq('linked-player') + end end describe 'PUT #update' do @@ -46,6 +57,35 @@ 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.player).to eq(player) + expect(deserialize_response(response).dig(:player, :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.player.email).to eq('new-player@example.com') + expect(@team.player.username).to eq('new-player') + end + + it 'clears player association with blank player_id' do + @team.update!(player: create(:user)) + + put :update, params: { id: @team.to_param, player_id: '' } + + expect(response).to be_successful + expect(@team.reload.player).to be_nil + end end context 'with valid params as another user' do @@ -58,5 +98,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.update!(player: @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.player).to eq(@player) + end + end end end diff --git a/spec/controllers/tournaments_controller_spec.rb b/spec/controllers/tournaments_controller_spec.rb index 4f396b3..5362bba 100644 --- a/spec/controllers/tournaments_controller_spec.rb +++ b/spec/controllers/tournaments_controller_spec.rb @@ -541,6 +541,25 @@ 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_email: 'alpha-player@example.com' }, + { name: 'Beta' }, + { name: 'Gamma' }, + { name: 'Delta' } + ] + + expect do + post :create, params: data + end.to change(User, :count).by(1) + + tournament = Tournament.find(deserialize_response(response)[:id]) + expect(tournament.teams.find_by(name: 'Alpha').player.email).to eq('alpha-player@example.com') + expect(tournament.teams.find_by(name: 'Beta').player).to be_nil + end end context 'with invalid parameters' do diff --git a/spec/e2e/http/api_surface_spec.rb b/spec/e2e/http/api_surface_spec.rb index f8600d7..02eb505 100644 --- a/spec/e2e/http/api_surface_spec.rb +++ b/spec/e2e/http/api_surface_spec.rb @@ -114,6 +114,61 @@ 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" + }) + 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/#{link_existing.dig(:json, :player, :id)}") + placeholder_profile = anonymous_client.get("/accounts/#{link_placeholder.dig(:json, :player, :id)}") + + expect(player_client.authenticated?).to eq(true) + expect(linked_team[:player]).to be_nil + + expect(link_existing[:status]).to eq(200) + expect(link_existing.dig(:json, :player, :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_placeholder[:status]).to eq(200) + expect(link_placeholder.dig(:json, :player, :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') @@ -562,12 +617,12 @@ RSpec.describe 'Backend API surface HTTP E2E' do expect(response[:json].map { |match| match.fetch(:state) }.uniq).to eq(['in_progress']) end - def create_playoff_tournament(client:, public:, name_prefix:) + def create_playoff_tournament(client:, public:, name_prefix:, teams: nil) payload = { name: "#{name_prefix} #{unique_suffix}", description: 'HTTP API surface E2E playoff tournament', public: public, - teams: 4.times.map do |index| + teams: teams || 4.times.map do |index| { name: "#{name_prefix.tr(' ', '')}-S#{index + 1}" } end } diff --git a/spec/models/team_spec.rb b/spec/models/team_spec.rb index d6d097e..6764c69 100644 --- a/spec/models/team_spec.rb +++ b/spec/models/team_spec.rb @@ -9,6 +9,7 @@ RSpec.describe Team, type: :model do describe 'association' do it { should belong_to(:tournament).optional } + it { should belong_to(:player).optional } it { should have_many :group_scores } it { should have_many :match_scores } it { should have_many :bets } diff --git a/spec/models/user_spec.rb b/spec/models/user_spec.rb index 83ab130..ead323b 100644 --- a/spec/models/user_spec.rb +++ b/spec/models/user_spec.rb @@ -5,6 +5,8 @@ require 'rails_helper' RSpec.describe User, type: :model do describe 'association' do it { should have_many :tournaments } + it { should have_many :player_teams } + it { should have_many :played_tournaments } it { should have_many :bets } 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 From 794f83514882d6fcb8c5ed7553d500da9f960e9b Mon Sep 17 00:00:00 2001 From: Malaber Date: Mon, 4 May 2026 14:55:48 +0200 Subject: [PATCH 29/36] fix(tournaments): keep owner data out metadata Tournament metadata remains optional and tournament-scoped. --- app/controllers/tournaments_controller.rb | 2 +- app/models/tournament.rb | 3 +-- app/serializers/simple_tournament_serializer.rb | 2 +- app/services/tournament_snapshot_importer.rb | 2 -- app/services/tournament_sync_schema.rb | 2 -- ...20260504100000_add_metadata_to_tournaments.rb | 2 -- db/schema.rb | 2 -- spec/controllers/tournaments_controller_spec.rb | 16 +--------------- spec/e2e/http/api_surface_spec.rb | 10 ---------- spec/e2e/http/tournament_follow_sync_spec.rb | 10 ---------- spec/e2e/http/tournament_sync_test_spec.rb | 4 ---- .../tournament_snapshot_importer_spec.rb | 6 ------ 12 files changed, 4 insertions(+), 57 deletions(-) diff --git a/app/controllers/tournaments_controller.rb b/app/controllers/tournaments_controller.rb index 5d05a5f..d82f58e 100644 --- a/app/controllers/tournaments_controller.rb +++ b/app/controllers/tournaments_controller.rb @@ -285,7 +285,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, :organizer_name, :contact_email, :website_url, + :location, :starts_at, :ends_at, :website_url, :read_only_mode, :sync_target_url, :sync_auth_token ).permit! end diff --git a/app/models/tournament.rb b/app/models/tournament.rb index 2b2f2d8..6f0ad3a 100644 --- a/app/models/tournament.rb +++ b/app/models/tournament.rb @@ -6,7 +6,7 @@ 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 organizer_name contact_email website_url].freeze + METADATA_STRING_ATTRIBUTES = %i[location website_url].freeze belongs_to :user belongs_to :owner, class_name: 'User', foreign_key: :user_id @@ -22,7 +22,6 @@ class Tournament < ApplicationRecord 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 - validates :contact_email, format: URI::MailTo::EMAIL_REGEXP, allow_blank: true validate :sync_configuration_is_complete validate :playoff_teams_amount_is_positive_power_of_two diff --git a/app/serializers/simple_tournament_serializer.rb b/app/serializers/simple_tournament_serializer.rb index 6bb26e4..496cbf4 100644 --- a/app/serializers/simple_tournament_serializer.rb +++ b/app/serializers/simple_tournament_serializer.rb @@ -1,7 +1,7 @@ # frozen_string_literal: true class SimpleTournamentSerializer < ApplicationSerializer - attributes :name, :code, :public, :location, :organizer_name, :contact_email, :website_url + attributes :name, :code, :public, :location, :website_url attribute :starts_at do object.starts_at&.iso8601 diff --git a/app/services/tournament_snapshot_importer.rb b/app/services/tournament_snapshot_importer.rb index 0b443f3..db97c91 100644 --- a/app/services/tournament_snapshot_importer.rb +++ b/app/services/tournament_snapshot_importer.rb @@ -7,8 +7,6 @@ class TournamentSnapshotImporter description public location - organizer_name - contact_email website_url playoff_teams_amount instant_finalists_amount diff --git a/app/services/tournament_sync_schema.rb b/app/services/tournament_sync_schema.rb index 7c9f436..f395516 100644 --- a/app/services/tournament_sync_schema.rb +++ b/app/services/tournament_sync_schema.rb @@ -12,8 +12,6 @@ class TournamentSyncSchema location starts_at ends_at - organizer_name - contact_email website_url playoff_teams_amount instant_finalists_amount diff --git a/db/migrate/20260504100000_add_metadata_to_tournaments.rb b/db/migrate/20260504100000_add_metadata_to_tournaments.rb index 5d10c4e..bcb19f0 100644 --- a/db/migrate/20260504100000_add_metadata_to_tournaments.rb +++ b/db/migrate/20260504100000_add_metadata_to_tournaments.rb @@ -6,8 +6,6 @@ class AddMetadataToTournaments < ActiveRecord::Migration[7.0] t.string :location t.datetime :starts_at t.datetime :ends_at - t.string :organizer_name - t.string :contact_email t.string :website_url end end diff --git a/db/schema.rb b/db/schema.rb index 766e949..4b41a05 100644 --- a/db/schema.rb +++ b/db/schema.rb @@ -163,7 +163,6 @@ ActiveRecord::Schema[8.1].define(version: 2026_05_04_100000) do create_table "tournaments", force: :cascade do |t| t.string "code", null: false - t.string "contact_email" t.datetime "created_at", precision: nil, null: false t.string "description" t.datetime "ends_at" @@ -171,7 +170,6 @@ ActiveRecord::Schema[8.1].define(version: 2026_05_04_100000) do t.integer "intermediate_round_participants_amount", default: 0 t.string "location" t.string "name", null: false - t.string "organizer_name" t.integer "playoff_teams_amount", default: 0 t.boolean "public", default: true t.boolean "read_only_mode", default: false, null: false diff --git a/spec/controllers/tournaments_controller_spec.rb b/spec/controllers/tournaments_controller_spec.rb index fdbc7d2..87b0b2a 100644 --- a/spec/controllers/tournaments_controller_spec.rb +++ b/spec/controllers/tournaments_controller_spec.rb @@ -105,7 +105,7 @@ RSpec.describe TournamentsController, type: :controller do expected_keys = %i[ id name code public description playoff_teams_amount instant_finalists_amount intermediate_round_participants_amount - location starts_at ends_at organizer_name contact_email website_url + 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) @@ -416,8 +416,6 @@ RSpec.describe TournamentsController, type: :controller do location: 'Stadtpark Halle', starts_at: starts_at, ends_at: ends_at, - organizer_name: 'BPWSTR Crew', - contact_email: 'orga@example.com', website_url: 'https://example.com/tournaments/summer-cup' ) @@ -426,8 +424,6 @@ RSpec.describe TournamentsController, type: :controller do 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.organizer_name).to eq('BPWSTR Crew') - expect(tournament.contact_email).to eq('orga@example.com') expect(tournament.website_url).to eq('https://example.com/tournaments/summer-cup') end @@ -620,8 +616,6 @@ RSpec.describe TournamentsController, type: :controller do location: 'Arena Nord', starts_at: starts_at, ends_at: ends_at, - organizer_name: 'Turniere Ops', - contact_email: 'ops@example.com', website_url: 'https://example.com/events/arena-nord' } @@ -631,8 +625,6 @@ RSpec.describe TournamentsController, type: :controller do 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.organizer_name).to eq('Turniere Ops') - expect(@tournament.contact_email).to eq('ops@example.com') 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) @@ -643,14 +635,12 @@ RSpec.describe TournamentsController, type: :controller do id: @tournament.to_param, starts_at: '2030-06-01T18:00:00+02:00', ends_at: '2030-06-01T10:00:00+02:00', - contact_email: 'not-an-email', 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[:contact_email]).to include('is invalid') expect(json[:website_url]).to include('is invalid') end @@ -880,8 +870,6 @@ RSpec.describe TournamentsController, type: :controller do location: 'Synced Arena', starts_at: '2030-06-01T10:00:00+02:00', ends_at: '2030-06-01T18:00:00+02:00', - organizer_name: 'Synced Crew', - contact_email: 'sync@example.com', website_url: 'https://example.com/synced', playoff_teams_amount: 4, instant_finalists_amount: 4, @@ -903,8 +891,6 @@ RSpec.describe TournamentsController, type: :controller do 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.organizer_name).to eq('Synced Crew') - expect(@tournament.contact_email).to eq('sync@example.com') 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) diff --git a/spec/e2e/http/api_surface_spec.rb b/spec/e2e/http/api_surface_spec.rb index a92fbac..b521e8c 100644 --- a/spec/e2e/http/api_surface_spec.rb +++ b/spec/e2e/http/api_surface_spec.rb @@ -85,8 +85,6 @@ RSpec.describe 'Backend API surface HTTP E2E' do 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, :organizer_name)).to eq('Turniere E2E') - expect(full_show.dig(:json, :contact_email)).to eq('events@example.com') expect(full_show.dig(:json, :website_url)).to eq('https://example.com/turniere-e2e') expect(invalid_index[:status]).to eq(400) @@ -128,8 +126,6 @@ RSpec.describe 'Backend API surface HTTP E2E' do location: 'Updated E2E Arena', starts_at: '2030-07-01T10:00:00+02:00', ends_at: '2030-07-01T18:00:00+02:00', - organizer_name: 'Updated E2E Org', - contact_email: 'updated-events@example.com', website_url: 'https://example.com/updated-turniere-e2e', playoff_teams_amount: 8, instant_finalists_amount: 8, @@ -187,8 +183,6 @@ RSpec.describe 'Backend API surface HTTP E2E' do 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, :organizer_name)).to eq('Updated E2E Org') - expect(update_response.dig(:json, :contact_email)).to eq('updated-events@example.com') expect(update_response.dig(:json, :website_url)).to eq('https://example.com/updated-turniere-e2e') expect(auto_recalc_response[:status]).to eq(200) @@ -532,8 +526,6 @@ RSpec.describe 'Backend API surface HTTP E2E' do location: "Venue #{name_prefix}", starts_at: '2030-06-01T10:00:00+02:00', ends_at: '2030-06-01T18:00:00+02:00', - organizer_name: 'Turniere E2E', - contact_email: 'events@example.com', website_url: 'https://example.com/turniere-e2e', group_stage: true, playoff_teams_amount: 8, @@ -594,8 +586,6 @@ RSpec.describe 'Backend API surface HTTP E2E' do location: "Venue #{name_prefix}", starts_at: '2030-06-01T10:00:00+02:00', ends_at: '2030-06-01T18:00:00+02:00', - organizer_name: 'Turniere E2E', - contact_email: 'events@example.com', website_url: 'https://example.com/turniere-e2e', teams: 4.times.map do |index| { name: "#{name_prefix.tr(' ', '')}-S#{index + 1}" } diff --git a/spec/e2e/http/tournament_follow_sync_spec.rb b/spec/e2e/http/tournament_follow_sync_spec.rb index 7560aed..bf27008 100644 --- a/spec/e2e/http/tournament_follow_sync_spec.rb +++ b/spec/e2e/http/tournament_follow_sync_spec.rb @@ -114,8 +114,6 @@ RSpec.describe 'Tournament follower sync HTTP E2E' do location: 'Source Updated Arena', starts_at: '2030-07-01T10:00:00+02:00', ends_at: '2030-07-01T18:00:00+02:00', - organizer_name: 'Updated Sync Crew', - contact_email: 'updated-sync@example.com', website_url: 'https://example.com/updated-sync-source', playoff_teams_amount: 4, instant_finalists_amount: 4, @@ -242,8 +240,6 @@ RSpec.describe 'Tournament follower sync HTTP E2E' do location: "Source Venue #{name_prefix}", starts_at: '2030-06-01T10:00:00+02:00', ends_at: '2030-06-01T18:00:00+02:00', - organizer_name: 'Sync Source Crew', - contact_email: 'sync-source@example.com', website_url: 'https://example.com/sync-source', group_stage: true, playoff_teams_amount: 8, @@ -270,8 +266,6 @@ RSpec.describe 'Tournament follower sync HTTP E2E' do location: "Noise Venue #{name_prefix}", starts_at: '2030-06-01T10:00:00+02:00', ends_at: '2030-06-01T18:00:00+02:00', - organizer_name: 'Noise Crew', - contact_email: 'noise@example.com', website_url: 'https://example.com/noise', teams: 4.times.map do |index| { name: "#{name_prefix.tr(' ', '')}-S#{index + 1}" } @@ -291,8 +285,6 @@ RSpec.describe 'Tournament follower sync HTTP E2E' do location: "Follower Venue #{name_prefix}", starts_at: '2030-05-01T10:00:00+02:00', ends_at: '2030-05-01T18:00:00+02:00', - organizer_name: 'Follower Crew', - contact_email: 'follower@example.com', website_url: 'https://example.com/follower', read_only_mode: true, sync_auth_token: sync_token @@ -527,8 +519,6 @@ RSpec.describe 'Tournament follower sync HTTP E2E' do location: tournament[:location], starts_at: tournament[:starts_at], ends_at: tournament[:ends_at], - organizer_name: tournament[:organizer_name], - contact_email: tournament[:contact_email], website_url: tournament[:website_url], playoff_teams_amount: tournament.fetch(:playoff_teams_amount), instant_finalists_amount: tournament.fetch(:instant_finalists_amount), diff --git a/spec/e2e/http/tournament_sync_test_spec.rb b/spec/e2e/http/tournament_sync_test_spec.rb index 8df3498..a3f698b 100644 --- a/spec/e2e/http/tournament_sync_test_spec.rb +++ b/spec/e2e/http/tournament_sync_test_spec.rb @@ -37,8 +37,6 @@ RSpec.describe 'Tournament sync test HTTP E2E' do location: 'Sync Test Arena', starts_at: '2030-08-01T10:00:00+02:00', ends_at: '2030-08-01T18:00:00+02:00', - organizer_name: 'Sync Test Org', - contact_email: 'sync-test@example.com', website_url: 'https://example.com/sync-test' }) expect(rename[:status]).to eq(200) @@ -61,8 +59,6 @@ RSpec.describe 'Tournament sync test HTTP E2E' do 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(:organizer_name)).to eq(after_leader.fetch(:organizer_name)) - expect(after_follower.fetch(:contact_email)).to eq(after_leader.fetch(:contact_email)) 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) }) diff --git a/spec/services/tournament_snapshot_importer_spec.rb b/spec/services/tournament_snapshot_importer_spec.rb index f9b563b..34e4e6a 100644 --- a/spec/services/tournament_snapshot_importer_spec.rb +++ b/spec/services/tournament_snapshot_importer_spec.rb @@ -10,8 +10,6 @@ RSpec.describe TournamentSnapshotImporter do location: 'Court 4', starts_at: Time.utc(2026, 4, 24, 9, 0, 0), ends_at: Time.utc(2026, 4, 24, 18, 30, 0), - organizer_name: 'Sync Ops', - contact_email: 'sync-ops@example.com', website_url: 'https://example.com/sync-cup', timestamp: Time.utc(2026, 4, 24, 10, 15, 0), timer_mode: 'countdown', @@ -37,8 +35,6 @@ RSpec.describe TournamentSnapshotImporter do 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.organizer_name).to eq(source.organizer_name) - expect(follower.contact_email).to eq(source.contact_email) 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) @@ -212,8 +208,6 @@ RSpec.describe TournamentSnapshotImporter do location: tournament.location, starts_at: tournament.starts_at&.iso8601, ends_at: tournament.ends_at&.iso8601, - organizer_name: tournament.organizer_name, - contact_email: tournament.contact_email, website_url: tournament.website_url, playoff_teams_amount: tournament.playoff_teams_amount, instant_finalists_amount: tournament.instant_finalists_amount, From bbaca69265db2af2f4b7d08eb57ff893776650c2 Mon Sep 17 00:00:00 2001 From: Malaber Date: Mon, 4 May 2026 15:06:08 +0200 Subject: [PATCH 30/36] feat: allow unplayed playoff deletion --- app/controllers/tournaments_controller.rb | 45 +++++- app/services/tournament_playoff_deletion.rb | 58 +++++++ config/routes.rb | 1 + e2e/lib/scenario_runner.rb | 107 +++++++++++++ script/e2e_scenarios.rb | 2 + .../tournaments_controller_spec.rb | 113 +++++++++++++ spec/e2e/http/tournament_follow_sync_spec.rb | 151 +++++++++++++++++- spec/e2e/http/tournament_lifecycle_spec.rb | 12 ++ spec/routing/tournaments_routing_spec.rb | 4 + tasks.py | 22 +++ 10 files changed, 507 insertions(+), 8 deletions(-) create mode 100644 app/services/tournament_playoff_deletion.rb diff --git a/app/controllers/tournaments_controller.rb b/app/controllers/tournaments_controller.rb index f878c77..2eb3b7e 100644 --- a/app/controllers/tournaments_controller.rb +++ b/app/controllers/tournaments_controller.rb @@ -13,17 +13,17 @@ class TournamentsController < ApplicationController before_action :set_tournament_for_show, only: %i[show] before_action :set_tournament, - only: %i[update destroy set_timer timer sync_state test_sync start_next_group_matches] - before_action :authenticate_user!, only: %i[create update destroy set_timer test_sync start_next_group_matches] + 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 set_timer test_sync start_next_group_matches] + 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[start_next_group_matches] + 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 @@ -130,6 +130,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) @@ -222,6 +246,19 @@ class TournamentsController < ApplicationController } 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 + def organize_teams_in_groups(teams) # each team gets put into an array of teams depending on the group specified in team[:group] teams.group_by { |team| team['group'] }.values.map do |group| diff --git a/app/services/tournament_playoff_deletion.rb b/app/services/tournament_playoff_deletion.rb new file mode 100644 index 0000000..fbeafd8 --- /dev/null +++ b/app/services/tournament_playoff_deletion.rb @@ -0,0 +1,58 @@ +# 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? + unless playoffs_deletable?(playoff_stages) + return failure('Playoffs cannot be deleted after matches have started or scores changed') + end + + 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/config/routes.rb b/config/routes.rb index 3176d07..fb289e7 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -37,6 +37,7 @@ Rails.application.routes.draw do 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] diff --git a/e2e/lib/scenario_runner.rb b/e2e/lib/scenario_runner.rb index dace468..b8e20b0 100644 --- a/e2e/lib/scenario_runner.rb +++ b/e2e/lib/scenario_runner.rb @@ -109,6 +109,43 @@ module TurniereE2E 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_team_names: playoff_team_names(tournament) + ) + return result(session: session, tournament: tournament, checkpoints: checkpoints, stop_at: stop_at) if stop_at == created_key + + 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) + return result(session: session, tournament: tournament, checkpoints: checkpoints, stop_at: stop_at) if stop_at == deleted_key + 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: team_count)) @@ -433,6 +470,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 @@ -487,6 +530,34 @@ module TurniereE2E 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 + def preferred_playoff_winner_name(match) match.fetch(:match_scores) .map { |score| score.dig(:team, :name) } @@ -521,6 +592,35 @@ module TurniereE2E } 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)}", @@ -611,6 +711,13 @@ module TurniereE2E } end + def playoff_team_names(tournament) + playoff_matches(tournament) + .flat_map { |match| match.fetch(:match_scores).map { |score| score.dig(:team, :name) } } + .uniq + .sort + end + def fetch_tournament_profile!(tournament_id) started_at = Process.clock_gettime(Process::CLOCK_MONOTONIC) response = get_response!("/tournaments/#{tournament_id}", params: { profile: 'true' }) diff --git a/script/e2e_scenarios.rb b/script/e2e_scenarios.rb index fad48d8..45da011 100644 --- a/script/e2e_scenarios.rb +++ b/script/e2e_scenarios.rb @@ -46,6 +46,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/controllers/tournaments_controller_spec.rb b/spec/controllers/tournaments_controller_spec.rb index 4f396b3..4e14c52 100644 --- a/spec/controllers/tournaments_controller_spec.rb +++ b/spec/controllers/tournaments_controller_spec.rb @@ -794,6 +794,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') diff --git a/spec/e2e/http/tournament_follow_sync_spec.rb b/spec/e2e/http/tournament_follow_sync_spec.rb index aa3f87c..f54c16c 100644 --- a/spec/e2e/http/tournament_follow_sync_spec.rb +++ b/spec/e2e/http/tournament_follow_sync_spec.rb @@ -214,6 +214,64 @@ RSpec.describe 'Tournament follower sync HTTP E2E' do 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_team_names(source)).to eq(round.fetch(:expected_playoff_team_names)) + expect(playoff_team_names(follower)).to eq(round.fetch(:expected_playoff_team_names)) + 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 @@ -228,15 +286,18 @@ RSpec.describe 'Tournament follower sync HTTP E2E' do 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, group_stage: true, - playoff_teams_amount: 8, - teams: 4.times.flat_map do |group_index| - 4.times.map 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 @@ -250,6 +311,10 @@ RSpec.describe 'Tournament follower sync HTTP E2E' do 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}", @@ -460,6 +525,84 @@ 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_team_names: %w[G1-S1 G2-S1] + }, + { + scores_by_team_name: { + 'G1-S1' => 0, + 'G1-S2' => 4, + 'G2-S1' => 0, + 'G2-S2' => 4 + }, + expected_playoff_team_names: %w[G1-S2 G2-S2] + }, + { + scores_by_team_name: { + 'G1-S1' => 5, + 'G1-S2' => 2, + 'G2-S1' => 2, + 'G2-S2' => 5 + }, + expected_playoff_team_names: %w[G1-S1 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_team_names(tournament) + tournament.fetch(:stages) + .select { |stage| stage.fetch(:level) >= 0 } + .flat_map { |stage| stage.fetch(:matches) } + .flat_map { |match| match.fetch(:match_scores).map { |score| seed_label(score.dig(:team, :name)) } } + .uniq + .sort + 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) diff --git a/spec/e2e/http/tournament_lifecycle_spec.rb b/spec/e2e/http/tournament_lifecycle_spec.rb index 437c063..22c3970 100644 --- a/spec/e2e/http/tournament_lifecycle_spec.rb +++ b/spec/e2e/http/tournament_lifecycle_spec.rb @@ -61,6 +61,18 @@ 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_team_names)).to eq(%w[G1-S1 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_team_names)).to eq(%w[G1-S2 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_team_names)).to eq(%w[G1-S1 G2-S2]) + end + { won: [1, 2], lost: [2, 1], diff --git a/spec/routing/tournaments_routing_spec.rb b/spec/routing/tournaments_routing_spec.rb index 0c185eb..77b85bb 100644 --- a/spec/routing/tournaments_routing_spec.rb +++ b/spec/routing/tournaments_routing_spec.rb @@ -28,6 +28,10 @@ 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 diff --git a/tasks.py b/tasks.py index 2e5efe6..e9c986f 100644 --- a/tasks.py +++ b/tasks.py @@ -1041,6 +1041,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, From 0cbcb64066b6db7a2f3ace5c1f610179aa9ba1fa Mon Sep 17 00:00:00 2001 From: Malaber Date: Mon, 4 May 2026 15:10:11 +0200 Subject: [PATCH 31/36] feat(matches): add match metadata updates --- app/controllers/matches_controller.rb | 73 ++++++++++- app/serializers/live_match_serializer.rb | 2 +- app/serializers/match_serializer.rb | 2 +- app/services/tournament_snapshot_importer.rb | 3 + app/services/tournament_sync_schema.rb | 2 +- config/routes.rb | 6 +- ...504100000_add_match_metadata_to_matches.rb | 9 ++ db/schema.rb | 5 +- spec/controllers/matches_controller_spec.rb | 121 +++++++++++++++++- spec/e2e/http/tournament_follow_sync_spec.rb | 62 +++++++++ spec/routing/matches_routing_spec.rb | 4 + spec/services/tournament_live_payload_spec.rb | 7 +- .../tournament_snapshot_importer_spec.rb | 18 +++ tasks.py | 7 +- 14 files changed, 308 insertions(+), 13 deletions(-) create mode 100644 db/migrate/20260504100000_add_match_metadata_to_matches.rb diff --git a/app/controllers/matches_controller.rb b/app/controllers/matches_controller.rb index 42477ba..2ea5ac3 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 @@ -63,6 +65,28 @@ class MatchesController < ApplicationController 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) @@ -141,7 +165,7 @@ class MatchesController < ApplicationController old_state = @match.state if @match.update(match_params) - record_successful_match_update(old_state, new_state) + record_successful_match_update(old_state, new_state) unless new_state.nil? render json: @match tournament else @@ -151,6 +175,8 @@ class MatchesController < ApplicationController 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? @@ -175,6 +201,8 @@ class MatchesController < ApplicationController end def validate_params + return unless params.key?(:state) + transition_error = match_transition_error(@match, match_params['state']) return if transition_error.nil? @@ -201,13 +229,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 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/services/tournament_snapshot_importer.rb b/app/services/tournament_snapshot_importer.rb index c2868da..5ddf768 100644 --- a/app/services/tournament_snapshot_importer.rb +++ b/app/services/tournament_snapshot_importer.rb @@ -139,6 +139,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 ee0a9ac..b4102ef 100644 --- a/app/services/tournament_sync_schema.rb +++ b/app/services/tournament_sync_schema.rb @@ -48,7 +48,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/config/routes.rb b/config/routes.rb index 3176d07..ba25f79 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -24,7 +24,11 @@ Rails.application.routes.draw 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 diff --git a/db/migrate/20260504100000_add_match_metadata_to_matches.rb b/db/migrate/20260504100000_add_match_metadata_to_matches.rb new file mode 100644 index 0000000..4a7b131 --- /dev/null +++ b/db/migrate/20260504100000_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/schema.rb b/db/schema.rb index 773e654..d700640 100644 --- a/db/schema.rb +++ b/db/schema.rb @@ -10,7 +10,7 @@ # # It's strongly recommended that you check this file into your version control system. -ActiveRecord::Schema[8.1].define(version: 2026_04_30_121000) do +ActiveRecord::Schema[8.1].define(version: 2026_05_04_100000) do create_table "beamers", force: :cascade do |t| t.json "config", default: {}, null: false t.datetime "created_at", null: false @@ -74,8 +74,11 @@ ActiveRecord::Schema[8.1].define(version: 2026_04_30_121000) do 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.string "start_time" t.integer "state", default: 0 t.integer "sync_source_id" t.datetime "updated_at", precision: nil, null: false diff --git a/spec/controllers/matches_controller_spec.rb b/spec/controllers/matches_controller_spec.rb index 60c92a2..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 @@ -196,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 @@ -237,6 +333,29 @@ 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) diff --git a/spec/e2e/http/tournament_follow_sync_spec.rb b/spec/e2e/http/tournament_follow_sync_spec.rb index aa3f87c..8ce75a2 100644 --- a/spec/e2e/http/tournament_follow_sync_spec.rb +++ b/spec/e2e/http/tournament_follow_sync_spec.rb @@ -50,6 +50,51 @@ RSpec.describe 'Tournament follower sync HTTP E2E' do expect(tournament_signature(follower)).to eq(tournament_signature(source)) 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).sort_by { |group| group.fetch(:number) }.first @@ -144,6 +189,20 @@ RSpec.describe 'Tournament follower sync HTTP E2E' do 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) @@ -548,6 +607,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), 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/services/tournament_live_payload_spec.rb b/spec/services/tournament_live_payload_spec.rb index 8e5781d..7cb435f 100644 --- a/spec/services/tournament_live_payload_spec.rb +++ b/spec/services/tournament_live_payload_spec.rb @@ -26,9 +26,14 @@ 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].map { |team| team[:id] }).to match_array(match.teams.map(&:id)) diff --git a/spec/services/tournament_snapshot_importer_spec.rb b/spec/services/tournament_snapshot_importer_spec.rb index b86e6fe..9947d4c 100644 --- a/spec/services/tournament_snapshot_importer_spec.rb +++ b/spec/services/tournament_snapshot_importer_spec.rb @@ -12,6 +12,11 @@ RSpec.describe TournamentSnapshotImporter do 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', @@ -37,6 +42,11 @@ RSpec.describe TournamentSnapshotImporter do 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.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 @@ -136,6 +146,11 @@ RSpec.describe TournamentSnapshotImporter do 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', @@ -254,6 +269,9 @@ RSpec.describe TournamentSnapshotImporter do 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/tasks.py b/tasks.py index 2e5efe6..e598c43 100644 --- a/tasks.py +++ b/tasks.py @@ -751,9 +751,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") From ed299aba5730fc4fe29a16d77e0542231e4f7d5e Mon Sep 17 00:00:00 2001 From: Malaber Date: Mon, 4 May 2026 15:25:26 +0200 Subject: [PATCH 32/36] feat(accounts): support multiple team players Teams now use a join table so organizers can attach more than one player account while players still get public profile data. --- app/controllers/teams_controller.rb | 87 +++++++++++++------ app/controllers/tournaments_controller.rb | 14 +-- app/models/team.rb | 3 +- app/models/team_player.rb | 6 ++ app/models/user.rb | 3 +- app/serializers/team_serializer.rb | 6 +- app/serializers/tournament_serializer.rb | 2 +- app/services/tournament_sync_schema.rb | 2 +- .../20260504120000_add_player_to_teams.rb | 7 -- .../20260504120000_create_team_players.rb | 18 ++++ db/schema.rb | 15 +++- spec/controllers/accounts_controller_spec.rb | 4 +- spec/controllers/teams_controller_spec.rb | 40 ++++++--- .../tournaments_controller_spec.rb | 12 ++- spec/e2e/http/api_surface_spec.rb | 25 ++++-- spec/models/team_spec.rb | 3 +- spec/models/user_spec.rb | 1 + 17 files changed, 174 insertions(+), 74 deletions(-) create mode 100644 app/models/team_player.rb delete mode 100644 db/migrate/20260504120000_add_player_to_teams.rb create mode 100644 db/migrate/20260504120000_create_team_players.rb diff --git a/app/controllers/teams_controller.rb b/app/controllers/teams_controller.rb index 0a05825..0450240 100644 --- a/app/controllers/teams_controller.rb +++ b/app/controllers/teams_controller.rb @@ -13,16 +13,19 @@ class TeamsController < ApplicationController # PATCH/PUT /teams/1 def update - @team.assign_attributes(team_params) - assign_player_from_params if player_assignment_requested? + 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? - if @team.save - push_sync_if_needed!(@team.tournament) - render json: @team - else - render json: @team.errors, status: :unprocessable_content - end + push_sync_if_needed!(@team.tournament) + render json: @team + rescue ActiveRecord::RecordInvalid => e + render json: e.record.errors, status: :unprocessable_content end private @@ -36,40 +39,74 @@ class TeamsController < ApplicationController end def player_assignment_requested? - params.key?(:player_email) || params.key?(:player_id) + params.key?(:player_email) || params.key?(:player_id) || + params.key?(:player_emails) || params.key?(:player_ids) end - def assign_player_from_params + def assign_players_from_params unless tournament_owner? render_forbidden_error return end - @team.player = if params.key?(:player_email) - player_from_email - else - player_from_id - 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 player_from_email - email = params[:player_email].to_s.strip - return nil if email.blank? - - User.find_or_create_player_by_email!(email) + def players_from_emails(emails) + email_values(emails).map { |email| User.find_or_create_player_by_email!(email) } end - def player_from_id - player_id = params[:player_id].to_s.strip - return nil if player_id.blank? + 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.find(player_id) + User.where(id: id_values).tap do |players| + unless players.size == id_values.uniq.size + raise ActiveRecord::RecordNotFound.new('Could not find all players', 'User') + end + 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.player == current_user + return if tournament_owner? || @team.players.include?(current_user) render_forbidden_error end diff --git a/app/controllers/tournaments_controller.rb b/app/controllers/tournaments_controller.rb index ca202f5..b539bb0 100644 --- a/app/controllers/tournaments_controller.rb +++ b/app/controllers/tournaments_controller.rb @@ -236,15 +236,15 @@ class TournamentsController < ApplicationController if team[:id] Team.find team[:id] elsif team[:name] - Team.create name: team[:name], player: player_from_team_params(team) + Team.create(name: team[:name], players: players_from_team_params(team)) end end - def player_from_team_params(team) - player_email = team[:player_email].to_s.strip - return nil if player_email.blank? - - User.find_or_create_player_by_email!(player_email) + 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 def set_tournament @@ -256,7 +256,7 @@ class TournamentsController < ApplicationController @tournament = profiling.measure('load_tournament') do Tournament.includes( :user, - { teams: :player }, + { teams: :players }, stages: [ { matches: { match_scores: :team } }, { groups: [ diff --git a/app/models/team.rb b/app/models/team.rb index 6f39fd5..57c2a44 100644 --- a/app/models/team.rb +++ b/app/models/team.rb @@ -2,7 +2,8 @@ class Team < ApplicationRecord belongs_to :tournament, optional: true - belongs_to :player, class_name: 'User', optional: true, inverse_of: :player_teams + 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 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/user.rb b/app/models/user.rb index a887116..7e156c0 100644 --- a/app/models/user.rb +++ b/app/models/user.rb @@ -11,7 +11,8 @@ class User < ApplicationRecord validates :username, presence: true, uniqueness: { case_sensitive: false } has_many :tournaments, dependent: :destroy - has_many :player_teams, class_name: 'Team', foreign_key: :player_id, inverse_of: :player, dependent: :nullify + 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 diff --git a/app/serializers/team_serializer.rb b/app/serializers/team_serializer.rb index eaa4fb5..5799663 100644 --- a/app/serializers/team_serializer.rb +++ b/app/serializers/team_serializer.rb @@ -1,9 +1,9 @@ # frozen_string_literal: true class TeamSerializer < ApplicationSerializer - attributes :name, :player + attributes :name, :players - def player - AccountSummarySerializer.new(object.player).as_json if object.player + 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 cb60514..f23cf83 100644 --- a/app/serializers/tournament_serializer.rb +++ b/app/serializers/tournament_serializer.rb @@ -34,7 +34,7 @@ class TournamentSerializer < SimpleTournamentSerializer { id: team.id, name: team.name, - player: team.player && AccountSummarySerializer.new(team.player).as_json, + players: team.players.map { |player| AccountSummarySerializer.new(player).as_json }, advancing_from_group_stage: adv_teams.include?(team) } end diff --git a/app/services/tournament_sync_schema.rb b/app/services/tournament_sync_schema.rb index a103b5c..ee0a9ac 100644 --- a/app/services/tournament_sync_schema.rb +++ b/app/services/tournament_sync_schema.rb @@ -33,7 +33,7 @@ class TournamentSyncSchema }.freeze, Team => { synced: %w[id name].freeze, - ignored: %w[tournament_id player_id created_at updated_at sync_source_id].freeze + ignored: %w[tournament_id created_at updated_at sync_source_id].freeze }.freeze, Beamer => { synced: %w[id name display_state is_default config].freeze, diff --git a/db/migrate/20260504120000_add_player_to_teams.rb b/db/migrate/20260504120000_add_player_to_teams.rb deleted file mode 100644 index b300983..0000000 --- a/db/migrate/20260504120000_add_player_to_teams.rb +++ /dev/null @@ -1,7 +0,0 @@ -# frozen_string_literal: true - -class AddPlayerToTeams < ActiveRecord::Migration[7.0] - def change - add_reference :teams, :player, type: :integer, foreign_key: { to_table: :users }, index: true - 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 67ad9a4..1777a61 100644 --- a/db/schema.rb +++ b/db/schema.rb @@ -121,14 +121,22 @@ ActiveRecord::Schema[8.1].define(version: 2026_05_04_120000) do 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.datetime "created_at", precision: nil, null: false t.string "name" - t.integer "player_id" t.integer "sync_source_id" t.integer "tournament_id" t.datetime "updated_at", precision: nil, null: false - t.index ["player_id"], name: "index_teams_on_player_id" t.index ["tournament_id"], name: "index_teams_on_tournament_id" end @@ -235,8 +243,9 @@ ActiveRecord::Schema[8.1].define(version: 2026_05_04_120000) 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 "teams", "users", column: "player_id" 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 diff --git a/spec/controllers/accounts_controller_spec.rb b/spec/controllers/accounts_controller_spec.rb index e73d58a..95ac9b4 100644 --- a/spec/controllers/accounts_controller_spec.rb +++ b/spec/controllers/accounts_controller_spec.rb @@ -5,8 +5,8 @@ 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', player: account) } - let(:private_team) { create(:team, name: 'Private Team', player: account) } + 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) } diff --git a/spec/controllers/teams_controller_spec.rb b/spec/controllers/teams_controller_spec.rb index 778e551..bd6cc19 100644 --- a/spec/controllers/teams_controller_spec.rb +++ b/spec/controllers/teams_controller_spec.rb @@ -20,15 +20,15 @@ RSpec.describe TeamsController, type: :controller do expect(body[:name]).to eq(@team.name) end - it 'returns player account summary when linked' do + it 'returns player account summaries when linked' do player = create(:user, username: 'linked-player') - @team.update!(player: player) + @team.players << player get :show, params: { id: @team.to_param } body = deserialize_response response - expect(body.dig(:player, :id)).to eq(player.id) - expect(body.dig(:player, :name)).to eq('linked-player') + expect(body[:players].first[:id]).to eq(player.id) + expect(body[:players].first[:name]).to eq('linked-player') end end @@ -64,8 +64,8 @@ RSpec.describe TeamsController, type: :controller do put :update, params: { id: @team.to_param, player_email: player.email } expect(response).to be_successful - expect(@team.reload.player).to eq(player) - expect(deserialize_response(response).dig(:player, :id)).to eq(player.id) + 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 @@ -74,17 +74,31 @@ RSpec.describe TeamsController, type: :controller do end.to change(User, :count).by(1) expect(response).to be_successful - expect(@team.reload.player.email).to eq('new-player@example.com') - expect(@team.player.username).to eq('new-player') + expect(@team.reload.players.first.email).to eq('new-player@example.com') + expect(@team.players.first.username).to eq('new-player') end - it 'clears player association with blank player_id' do - @team.update!(player: create(:user)) + 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.player).to be_nil + expect(@team.reload.players).to be_empty end end @@ -102,7 +116,7 @@ RSpec.describe TeamsController, type: :controller do context 'with valid params as linked player' do before(:each) do @player = create(:user) - @team.update!(player: @player) + @team.players << @player apply_authentication_headers_for @player end @@ -119,7 +133,7 @@ RSpec.describe TeamsController, type: :controller do put :update, params: { id: @team.to_param, player_email: new_player.email } expect(response).to have_http_status(:forbidden) - expect(@team.reload.player).to eq(@player) + expect(@team.reload.players).to contain_exactly(@player) end end end diff --git a/spec/controllers/tournaments_controller_spec.rb b/spec/controllers/tournaments_controller_spec.rb index 5362bba..467499a 100644 --- a/spec/controllers/tournaments_controller_spec.rb +++ b/spec/controllers/tournaments_controller_spec.rb @@ -546,7 +546,10 @@ RSpec.describe TournamentsController, type: :controller do data = create_playoff_tournament_data data.delete :teams data[:teams] = [ - { name: 'Alpha', player_email: 'alpha-player@example.com' }, + { + name: 'Alpha', + player_emails: ['alpha-player@example.com', 'alpha-partner@example.com'] + }, { name: 'Beta' }, { name: 'Gamma' }, { name: 'Delta' } @@ -554,11 +557,12 @@ RSpec.describe TournamentsController, type: :controller do expect do post :create, params: data - end.to change(User, :count).by(1) + end.to change(User, :count).by(2) tournament = Tournament.find(deserialize_response(response)[:id]) - expect(tournament.teams.find_by(name: 'Alpha').player.email).to eq('alpha-player@example.com') - expect(tournament.teams.find_by(name: 'Beta').player).to be_nil + 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 diff --git a/spec/e2e/http/api_surface_spec.rb b/spec/e2e/http/api_surface_spec.rb index 02eb505..43ce856 100644 --- a/spec/e2e/http/api_surface_spec.rb +++ b/spec/e2e/http/api_surface_spec.rb @@ -139,25 +139,34 @@ RSpec.describe 'Backend API surface HTTP E2E' do 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/#{link_existing.dig(:json, :player, :id)}") - placeholder_profile = anonymous_client.get("/accounts/#{link_placeholder.dig(:json, :player, :id)}") + 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[:player]).to be_nil + expect(linked_team[:players]).to eq([]) expect(link_existing[:status]).to eq(200) - expect(link_existing.dig(:json, :player, :name)).to eq(player_username) + 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, :player, :name)).to start_with('http-e2e-placeholder') + 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)) @@ -617,6 +626,12 @@ RSpec.describe 'Backend API surface HTTP E2E' do 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}", diff --git a/spec/models/team_spec.rb b/spec/models/team_spec.rb index 6764c69..33bcc69 100644 --- a/spec/models/team_spec.rb +++ b/spec/models/team_spec.rb @@ -9,7 +9,8 @@ RSpec.describe Team, type: :model do describe 'association' do it { should belong_to(:tournament).optional } - it { should belong_to(:player).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/user_spec.rb b/spec/models/user_spec.rb index ead323b..0571095 100644 --- a/spec/models/user_spec.rb +++ b/spec/models/user_spec.rb @@ -5,6 +5,7 @@ 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 } From 9c006e57d2f3d07627089214cb05926f76fb5465 Mon Sep 17 00:00:00 2001 From: Malaber Date: Mon, 4 May 2026 17:41:13 +0200 Subject: [PATCH 33/36] docs(matches): document metadata API --- doc/match_metadata_api.md | 86 +++++++++++++++++++++++++++++++++++++++ 1 file changed, 86 insertions(+) create mode 100644 doc/match_metadata_api.md 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. From bcc93099bf230883dc12bb53d1b21f72181cd60a Mon Sep 17 00:00:00 2001 From: Malaber Date: Mon, 4 May 2026 17:42:38 +0200 Subject: [PATCH 34/36] test: assert playoff recreation matchups --- e2e/lib/scenario_runner.rb | 18 ++++++---- spec/e2e/http/tournament_follow_sync_spec.rb | 37 +++++++++++++------- spec/e2e/http/tournament_lifecycle_spec.rb | 15 ++++++-- 3 files changed, 49 insertions(+), 21 deletions(-) diff --git a/e2e/lib/scenario_runner.rb b/e2e/lib/scenario_runner.rb index b8e20b0..cee171a 100644 --- a/e2e/lib/scenario_runner.rb +++ b/e2e/lib/scenario_runner.rb @@ -130,7 +130,7 @@ module TurniereE2E created_key = created_keys.fetch(index) checkpoints[created_key] = summarize_tournament(tournament).merge( - playoff_team_names: playoff_team_names(tournament) + playoff_matchups: playoff_matchups(tournament) ) return result(session: session, tournament: tournament, checkpoints: checkpoints, stop_at: stop_at) if stop_at == created_key @@ -711,11 +711,17 @@ module TurniereE2E } end - def playoff_team_names(tournament) - playoff_matches(tournament) - .flat_map { |match| match.fetch(:match_scores).map { |score| score.dig(:team, :name) } } - .uniq - .sort + 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) diff --git a/spec/e2e/http/tournament_follow_sync_spec.rb b/spec/e2e/http/tournament_follow_sync_spec.rb index f54c16c..410bba8 100644 --- a/spec/e2e/http/tournament_follow_sync_spec.rb +++ b/spec/e2e/http/tournament_follow_sync_spec.rb @@ -248,8 +248,8 @@ RSpec.describe 'Tournament follower sync HTTP E2E' do 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_team_names(source)).to eq(round.fetch(:expected_playoff_team_names)) - expect(playoff_team_names(follower)).to eq(round.fetch(:expected_playoff_team_names)) + 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 @@ -534,7 +534,10 @@ RSpec.describe 'Tournament follower sync HTTP E2E' do 'G2-S1' => 3, 'G2-S2' => 1 }, - expected_playoff_team_names: %w[G1-S1 G2-S1] + expected_playoff_matchups: [ + %w[G1-S1 G2-S2], + %w[G1-S2 G2-S1] + ] }, { scores_by_team_name: { @@ -543,7 +546,10 @@ RSpec.describe 'Tournament follower sync HTTP E2E' do 'G2-S1' => 0, 'G2-S2' => 4 }, - expected_playoff_team_names: %w[G1-S2 G2-S2] + expected_playoff_matchups: [ + %w[G1-S2 G2-S1], + %w[G1-S1 G2-S2] + ] }, { scores_by_team_name: { @@ -552,7 +558,10 @@ RSpec.describe 'Tournament follower sync HTTP E2E' do 'G2-S1' => 2, 'G2-S2' => 5 }, - expected_playoff_team_names: %w[G1-S1 G2-S2] + expected_playoff_matchups: [ + %w[G1-S1 G2-S1], + %w[G1-S2 G2-S2] + ] } ] end @@ -590,13 +599,17 @@ RSpec.describe 'Tournament follower sync HTTP E2E' do end end - def playoff_team_names(tournament) - tournament.fetch(:stages) - .select { |stage| stage.fetch(:level) >= 0 } - .flat_map { |stage| stage.fetch(:matches) } - .flat_map { |match| match.fetch(:match_scores).map { |score| seed_label(score.dig(:team, :name)) } } - .uniq - .sort + 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) diff --git a/spec/e2e/http/tournament_lifecycle_spec.rb b/spec/e2e/http/tournament_lifecycle_spec.rb index 22c3970..ea5ff0a 100644 --- a/spec/e2e/http/tournament_lifecycle_spec.rb +++ b/spec/e2e/http/tournament_lifecycle_spec.rb @@ -65,12 +65,21 @@ RSpec.describe 'Tournament lifecycle HTTP E2E' do result = runner.run_group_stage_playoff_recreation checkpoints = result.fetch(:checkpoints) - expect(checkpoints.fetch(:first_playoffs_created).fetch(:playoff_team_names)).to eq(%w[G1-S1 G2-S1]) + 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_team_names)).to eq(%w[G1-S2 G2-S2]) + 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_team_names)).to eq(%w[G1-S1 G2-S2]) + expect(checkpoints.fetch(:third_playoffs_created).fetch(:playoff_matchups)).to eq([ + %w[G1-S1 G2-S1], + %w[G1-S2 G2-S2] + ]) end { From 0d466649957fbae6850c2584c0939753ee97d35b Mon Sep 17 00:00:00 2001 From: Malaber Date: Mon, 4 May 2026 17:47:12 +0200 Subject: [PATCH 35/36] fix(db): avoid duplicate migration version --- ...tches.rb => 20260504101000_add_match_metadata_to_matches.rb} | 0 db/schema.rb | 2 +- 2 files changed, 1 insertion(+), 1 deletion(-) rename db/migrate/{20260504100000_add_match_metadata_to_matches.rb => 20260504101000_add_match_metadata_to_matches.rb} (100%) diff --git a/db/migrate/20260504100000_add_match_metadata_to_matches.rb b/db/migrate/20260504101000_add_match_metadata_to_matches.rb similarity index 100% rename from db/migrate/20260504100000_add_match_metadata_to_matches.rb rename to db/migrate/20260504101000_add_match_metadata_to_matches.rb diff --git a/db/schema.rb b/db/schema.rb index 696dc12..46f5577 100644 --- a/db/schema.rb +++ b/db/schema.rb @@ -10,7 +10,7 @@ # # It's strongly recommended that you check this file into your version control system. -ActiveRecord::Schema[8.1].define(version: 2026_05_04_100000) do +ActiveRecord::Schema[8.1].define(version: 2026_05_04_101000) do create_table "beamers", force: :cascade do |t| t.json "config", default: {}, null: false t.datetime "created_at", null: false From 7d007c0415e6e1590c863713bf998d5c100a8edc Mon Sep 17 00:00:00 2001 From: Malaber Date: Wed, 6 May 2026 10:59:28 +0200 Subject: [PATCH 36/36] docs(api): document account profile endpoints Cover player account profile flows, team player association payloads, tournament metadata fields, and playoff deletion behavior. --- README.md | 4 + doc/account_profiles_api.md | 350 +++++++++++++++++++++++++++++++ doc/tournament_management_api.md | 193 +++++++++++++++++ 3 files changed, 547 insertions(+) create mode 100644 doc/account_profiles_api.md create mode 100644 doc/tournament_management_api.md 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/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/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