Merge remote-tracking branch 'origin/master' into codex/tur-225-user-organizer-accounts

# Conflicts:
#	db/schema.rb
#	spec/e2e/http/api_surface_spec.rb
This commit is contained in:
Daniel Schädler 2026-05-04 19:21:01 +02:00
commit 27ed1e191c
28 changed files with 1107 additions and 22 deletions

View File

@ -7,9 +7,11 @@ class MatchesController < ApplicationController
before_action :set_swap_target, only: %i[swap] before_action :set_swap_target, only: %i[swap]
before_action -> { require_owner! @match.owner }, only: %i[update swap] before_action -> { require_owner! @match.owner }, only: %i[update swap]
before_action -> { require_owner! @group.owner }, only: %i[create] before_action -> { require_owner! @group.owner }, only: %i[create]
before_action :set_tournament, only: %i[index] before_action :set_tournament, only: %i[index metadata]
before_action -> { require_owner! @tournament.owner }, only: %i[metadata]
before_action -> { require_writable_tournament!(@match.tournament) }, only: %i[update swap] before_action -> { require_writable_tournament!(@match.tournament) }, only: %i[update swap]
before_action -> { require_writable_tournament!(@group.tournament) }, only: %i[create] before_action -> { require_writable_tournament!(@group.tournament) }, only: %i[create]
before_action -> { require_writable_tournament!(@tournament) }, only: %i[metadata]
# GET/tournaments/1/matches # GET/tournaments/1/matches
def index def index
@ -63,6 +65,28 @@ class MatchesController < ApplicationController
TournamentLiveBroadcast.call(synced_tournament.id) unless synced_tournament.nil? TournamentLiveBroadcast.call(synced_tournament.id) unless synced_tournament.nil?
end end
# PATCH /tournaments/1/matches/metadata
def metadata
attrs = match_metadata_params
if attrs.empty?
render json: { error: 'At least one metadata field is required' }, status: :bad_request
return
end
matches = bulk_metadata_matches
return if performed?
Match.transaction do
TournamentTransactionLogger.lock_tournament!(@tournament)
@tournament.reload
matches.each { |match| match.update!(attrs) }
end
render json: matches.map(&:reload), each_serializer: LiveMatchSerializer, include: %w[match_scores.team]
push_sync_if_needed!(@tournament)
TournamentLiveBroadcast.call(@tournament.id)
end
# PATCH /matches/1/swap # PATCH /matches/1/swap
def swap def swap
unless @match.swappable_with?(@swap_target) unless @match.swappable_with?(@swap_target)
@ -141,7 +165,7 @@ class MatchesController < ApplicationController
old_state = @match.state old_state = @match.state
if @match.update(match_params) 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 render json: @match
tournament tournament
else else
@ -151,6 +175,8 @@ class MatchesController < ApplicationController
end end
def validate_transition_inside_transaction(new_state) def validate_transition_inside_transaction(new_state)
return if new_state.nil?
transition_error = match_transition_error(@match, new_state) transition_error = match_transition_error(@match, new_state)
return if transition_error.nil? return if transition_error.nil?
@ -175,6 +201,8 @@ class MatchesController < ApplicationController
end end
def validate_params def validate_params
return unless params.key?(:state)
transition_error = match_transition_error(@match, match_params['state']) transition_error = match_transition_error(@match, match_params['state'])
return if transition_error.nil? return if transition_error.nil?
@ -201,13 +229,52 @@ class MatchesController < ApplicationController
end end
def match_params def match_params
params.slice(:state).permit! params.slice(:state, :location, :start_time, :notes).permit!
end end
def match_index_params def match_index_params
params.slice(:state, :stage_id, :stage_level, :position, :group_stage_position).permit! params.slice(:state, :stage_id, :stage_level, :position, :group_stage_position).permit!
end end
def match_metadata_params
params.slice(:location, :start_time, :notes).permit!
end
def bulk_metadata_matches
return bulk_metadata_matches_by_id if params.key?(:match_ids)
return @tournament.matches if ActiveModel::Type::Boolean.new.cast(params[:all])
return filtered_bulk_metadata_matches if match_index_params.values.any?(&:present?)
render json: { error: 'Select matches with match_ids, filters, or all=true' }, status: :bad_request
[]
end
def bulk_metadata_matches_by_id
ids = parse_match_ids
return [] if performed?
matches = @tournament.matches.select { |match| ids.include?(match.id) }
missing_ids = ids - matches.map(&:id)
if missing_ids.any?
render json: { error: "Matches not found in tournament: #{missing_ids.join(', ')}" }, status: :not_found
return []
end
ids.map { |id| matches.find { |match| match.id == id } }
end
def filtered_bulk_metadata_matches
TournamentMatchesPayload.matches_for(@tournament, match_index_params)
end
def parse_match_ids
Array(params[:match_ids]).map { |id| Integer(id.to_s, 10) }.uniq
rescue ArgumentError, TypeError
render json: { error: 'match_ids must contain only integer ids' }, status: :bad_request
[]
end
def create_match_params def create_match_params
params.slice(:team_ids).permit(team_ids: []) params.slice(:team_ids).permit(team_ids: [])
end end

View File

@ -13,17 +13,17 @@ class TournamentsController < ApplicationController
before_action :set_tournament_for_show, only: %i[show] before_action :set_tournament_for_show, only: %i[show]
before_action :set_tournament, before_action :set_tournament,
only: %i[update destroy set_timer timer sync_state 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 set_timer 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 }, 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_create_params, only: %i[create]
before_action :validate_update_params, only: %i[update] before_action :validate_update_params, only: %i[update]
before_action :validate_set_timer_params, only: %i[set_timer] before_action :validate_set_timer_params, only: %i[set_timer]
before_action :ensure_update_allowed!, only: %i[update] before_action :ensure_update_allowed!, only: %i[update]
before_action :ensure_destroy_allowed!, only: %i[destroy] before_action :ensure_destroy_allowed!, only: %i[destroy]
before_action :ensure_timer_update_allowed!, only: %i[set_timer] before_action :ensure_timer_update_allowed!, only: %i[set_timer]
before_action -> { require_writable_tournament!(@tournament) }, only: %i[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] before_action :authorize_sync_request!, only: %i[sync_state]
rescue_from ActiveRecord::RecordNotFound, with: :render_not_found_error rescue_from ActiveRecord::RecordNotFound, with: :render_not_found_error
@ -130,6 +130,30 @@ class TournamentsController < ApplicationController
@tournament.destroy @tournament.destroy
end end
# DELETE /tournaments/:id/playoffs
def destroy_playoffs
synced_tournament = nil
Tournament.transaction do
TournamentTransactionLogger.lock_tournament!(@tournament)
@tournament.reload
result = TournamentPlayoffDeletion.call(tournament: @tournament)
unless result.success?
render json: { error: result.error }, status: :unprocessable_content
raise ActiveRecord::Rollback
end
synced_tournament = @tournament.reload
render json: playoff_deletion_payload(synced_tournament)
end
return if synced_tournament.nil?
push_sync_if_needed!(synced_tournament)
TournamentLiveBroadcast.call(synced_tournament.id)
end
# GET /tournaments/:id/timer # GET /tournaments/:id/timer
def timer def timer
render json: serialize_timer_state(@tournament) render json: serialize_timer_state(@tournament)
@ -223,6 +247,19 @@ class TournamentsController < ApplicationController
} }
end 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) def organize_teams_in_groups(teams)
# each team gets put into an array of teams depending on the group specified in team[:group] # 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| teams.group_by { |team| team['group'] }.values.map do |group|
@ -293,6 +330,7 @@ class TournamentsController < ApplicationController
params.slice( params.slice(
:name, :description, :public, :teams, :group_stage, :playoff_teams_amount, :name, :description, :public, :teams, :group_stage, :playoff_teams_amount,
:instant_finalists_amount, :intermediate_round_participants_amount, :instant_finalists_amount, :intermediate_round_participants_amount,
:location, :starts_at, :ends_at, :website_url,
:read_only_mode, :sync_target_url, :sync_auth_token :read_only_mode, :sync_target_url, :sync_auth_token
).permit! ).permit!
end end

View File

@ -6,6 +6,7 @@ require 'openssl'
class Tournament < ApplicationRecord class Tournament < ApplicationRecord
TIMER_MODES = %w[countdown countup].freeze TIMER_MODES = %w[countdown countup].freeze
TIMER_REASONS = %w[tournament_start playoff_start match_end custom].freeze TIMER_REASONS = %w[tournament_start playoff_start match_end custom].freeze
METADATA_STRING_ATTRIBUTES = %i[location website_url].freeze
belongs_to :user belongs_to :user
belongs_to :owner, class_name: 'User', foreign_key: :user_id belongs_to :owner, class_name: 'User', foreign_key: :user_id
@ -20,14 +21,17 @@ class Tournament < ApplicationRecord
validates :timer_mode, inclusion: { in: TIMER_MODES }, allow_nil: true validates :timer_mode, inclusion: { in: TIMER_MODES }, allow_nil: true
validates :timer_reason, inclusion: { in: TIMER_REASONS }, allow_nil: true validates :timer_reason, inclusion: { in: TIMER_REASONS }, allow_nil: true
validates :sync_target_url, format: URI::DEFAULT_PARSER.make_regexp(%w[http https]), allow_blank: true validates :sync_target_url, format: URI::DEFAULT_PARSER.make_regexp(%w[http https]), allow_blank: true
validates :website_url, format: URI::DEFAULT_PARSER.make_regexp(%w[http https]), allow_blank: true
validate :sync_configuration_is_complete validate :sync_configuration_is_complete
validate :playoff_teams_amount_is_positive_power_of_two validate :playoff_teams_amount_is_positive_power_of_two
validate :timer_reason_text_matches_reason validate :timer_reason_text_matches_reason
validate :ends_at_is_after_starts_at
after_initialize :generate_code after_initialize :generate_code
after_create_commit :ensure_default_beamer! after_create_commit :ensure_default_beamer!
before_validation :normalize_timer_reason before_validation :normalize_timer_reason
before_validation :normalize_metadata
before_validation :protect_follower_sync_token before_validation :protect_follower_sync_token
before_validation :clear_follower_sync_token_when_disabling_read_only_mode before_validation :clear_follower_sync_token_when_disabling_read_only_mode
after_commit :broadcast_timer_state_change, if: :saved_change_to_timer_state? after_commit :broadcast_timer_state_change, if: :saved_change_to_timer_state?
@ -154,6 +158,19 @@ class Tournament < ApplicationRecord
end end
end end
def normalize_metadata
METADATA_STRING_ATTRIBUTES.each do |attribute|
self[attribute] = self[attribute]&.strip&.presence
end
end
def ends_at_is_after_starts_at
return if starts_at.blank? || ends_at.blank?
return if ends_at > starts_at
errors.add(:ends_at, 'must be after starts_at')
end
def saved_change_to_timer_state? def saved_change_to_timer_state?
saved_change_to_timestamp? || saved_change_to_timestamp? ||
saved_change_to_timer_mode? || saved_change_to_timer_mode? ||

View File

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

View File

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

View File

@ -1,5 +1,13 @@
# frozen_string_literal: true # frozen_string_literal: true
class SimpleTournamentSerializer < ApplicationSerializer class SimpleTournamentSerializer < ApplicationSerializer
attributes :name, :code, :public attributes :name, :code, :public, :location, :website_url
attribute :starts_at do
object.starts_at&.iso8601
end
attribute :ends_at do
object.ends_at&.iso8601
end
end end

View File

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

View File

@ -22,6 +22,8 @@ class TournamentSnapshotBuilder
def build def build
payload = tournament.attributes.symbolize_keys.slice(*ROOT_ATTRIBUTES) payload = tournament.attributes.symbolize_keys.slice(*ROOT_ATTRIBUTES)
payload[:starts_at] = tournament.starts_at&.iso8601
payload[:ends_at] = tournament.ends_at&.iso8601
payload[:timestamp] = tournament.timestamp&.iso8601 payload[:timestamp] = tournament.timestamp&.iso8601
payload[:timer_mode] = tournament.timer_mode payload[:timer_mode] = tournament.timer_mode
payload[:timer_reason] = tournament.timer_reason payload[:timer_reason] = tournament.timer_reason

View File

@ -6,6 +6,8 @@ class TournamentSnapshotImporter
code code
description description
public public
location
website_url
playoff_teams_amount playoff_teams_amount
instant_finalists_amount instant_finalists_amount
intermediate_round_participants_amount intermediate_round_participants_amount
@ -67,6 +69,8 @@ class TournamentSnapshotImporter
def update_tournament! def update_tournament!
root_attrs = snapshot.slice(*ROOT_ATTRIBUTES) root_attrs = snapshot.slice(*ROOT_ATTRIBUTES)
root_attrs[:code] = tournament.code if code_taken_by_another_tournament?(root_attrs[:code]) root_attrs[:code] = tournament.code if code_taken_by_another_tournament?(root_attrs[:code])
root_attrs[:starts_at] = parse_time(snapshot[:starts_at])
root_attrs[:ends_at] = parse_time(snapshot[:ends_at])
root_attrs[:timestamp] = parse_time(snapshot[:timestamp]) root_attrs[:timestamp] = parse_time(snapshot[:timestamp])
root_attrs[:timer_mode] = snapshot[:timer_mode] root_attrs[:timer_mode] = snapshot[:timer_mode]
root_attrs[:timer_reason] = snapshot[:timer_reason] root_attrs[:timer_reason] = snapshot[:timer_reason]
@ -139,6 +143,9 @@ class TournamentSnapshotImporter
match.state = attrs.fetch(:state) match.state = attrs.fetch(:state)
match.position = attrs[:position] match.position = attrs[:position]
match.decider = attrs[:decider] match.decider = attrs[:decider]
match.location = attrs[:location]
match.start_time = attrs[:start_time]
match.notes = attrs[:notes]
end end
end end

View File

@ -9,6 +9,10 @@ class TournamentSyncSchema
code code
description description
public public
location
starts_at
ends_at
website_url
playoff_teams_amount playoff_teams_amount
instant_finalists_amount instant_finalists_amount
intermediate_round_participants_amount intermediate_round_participants_amount
@ -48,7 +52,7 @@ class TournamentSyncSchema
ignored: %w[created_at updated_at sync_source_id].freeze ignored: %w[created_at updated_at sync_source_id].freeze
}.freeze, }.freeze,
Match => { Match => {
synced: %w[id state position stage_id group_id decider].freeze, synced: %w[id state position stage_id group_id decider location start_time notes].freeze,
ignored: %w[created_at updated_at sync_source_id].freeze ignored: %w[created_at updated_at sync_source_id].freeze
}.freeze, }.freeze,
MatchScore => { MatchScore => {

View File

@ -25,7 +25,11 @@ Rails.application.routes.draw do
resources :team_action_lists, only: %i[create] resources :team_action_lists, only: %i[create]
patch 'team_action_lists/:key/teams/:team_id', to: 'team_action_lists#update_item' patch 'team_action_lists/:key/teams/:team_id', to: 'team_action_lists#update_item'
resources :statistics, only: %i[index] resources :statistics, only: %i[index]
resources :matches, only: %i[index] resources :matches, only: %i[index] do
collection do
patch :metadata
end
end
resources :beamers, only: %i[index show create update destroy] do resources :beamers, only: %i[index show create update destroy] do
collection do collection do
get :by_name get :by_name
@ -38,6 +42,7 @@ Rails.application.routes.draw do
patch :sync_state patch :sync_state
patch :start_next_group_matches patch :start_next_group_matches
post :test_sync post :test_sync
delete :playoffs, action: :destroy_playoffs
end end
end end
resources :match_scores, only: %i[show update] resources :match_scores, only: %i[show update]

View File

@ -0,0 +1,12 @@
# frozen_string_literal: true
class AddMetadataToTournaments < ActiveRecord::Migration[7.0]
def change
change_table :tournaments, bulk: true do |t|
t.string :location
t.datetime :starts_at
t.datetime :ends_at
t.string :website_url
end
end
end

View File

@ -0,0 +1,9 @@
# frozen_string_literal: true
class AddMatchMetadataToMatches < ActiveRecord::Migration[7.0]
def change
add_column :matches, :location, :string
add_column :matches, :start_time, :string
add_column :matches, :notes, :text
end
end

View File

@ -74,8 +74,11 @@ ActiveRecord::Schema[8.1].define(version: 2026_05_04_120000) do
t.datetime "created_at", precision: nil, null: false t.datetime "created_at", precision: nil, null: false
t.boolean "decider", default: false, null: false t.boolean "decider", default: false, null: false
t.integer "group_id" t.integer "group_id"
t.string "location"
t.text "notes"
t.integer "position" t.integer "position"
t.integer "stage_id" t.integer "stage_id"
t.string "start_time"
t.integer "state", default: 0 t.integer "state", default: 0
t.integer "sync_source_id" t.integer "sync_source_id"
t.datetime "updated_at", precision: nil, null: false t.datetime "updated_at", precision: nil, null: false
@ -175,12 +178,15 @@ ActiveRecord::Schema[8.1].define(version: 2026_05_04_120000) do
t.string "code", null: false t.string "code", null: false
t.datetime "created_at", precision: nil, null: false t.datetime "created_at", precision: nil, null: false
t.string "description" t.string "description"
t.datetime "ends_at"
t.integer "instant_finalists_amount", default: 0 t.integer "instant_finalists_amount", default: 0
t.integer "intermediate_round_participants_amount", default: 0 t.integer "intermediate_round_participants_amount", default: 0
t.string "location"
t.string "name", null: false t.string "name", null: false
t.integer "playoff_teams_amount", default: 0 t.integer "playoff_teams_amount", default: 0
t.boolean "public", default: true t.boolean "public", default: true
t.boolean "read_only_mode", default: false, null: false t.boolean "read_only_mode", default: false, null: false
t.datetime "starts_at"
t.string "sync_auth_token" t.string "sync_auth_token"
t.string "sync_auth_token_digest" t.string "sync_auth_token_digest"
t.datetime "sync_last_imported_snapshot_at" t.datetime "sync_last_imported_snapshot_at"
@ -194,6 +200,7 @@ ActiveRecord::Schema[8.1].define(version: 2026_05_04_120000) do
t.datetime "timestamp" t.datetime "timestamp"
t.datetime "updated_at", precision: nil, null: false t.datetime "updated_at", precision: nil, null: false
t.integer "user_id", null: false t.integer "user_id", null: false
t.string "website_url"
t.index ["code"], name: "index_tournaments_on_code", unique: true t.index ["code"], name: "index_tournaments_on_code", unique: true
t.index ["user_id"], name: "index_tournaments_on_user_id" t.index ["user_id"], name: "index_tournaments_on_user_id"
end end

86
doc/match_metadata_api.md Normal file
View File

@ -0,0 +1,86 @@
# Match Metadata API
Matches can store optional scheduling/display metadata:
- `location`: string, for venue, court, stream, or table
- `start_time`: string, for a local time like `17:00` or an agreed display value
- `notes`: free text for one-off match information
All fields are optional and nullable. Omitted fields are left unchanged.
## Update One Match
Use `PATCH /matches/:id` to update metadata on one match. This endpoint still
accepts the existing `state` transitions, and metadata can be sent with or
without `state`.
```json
{
"location": "Main Hall",
"start_time": "17:00",
"notes": "Opening match. Stream setup needed."
}
```
Owner authentication is required. Read-only follower tournaments reject this
with `423 Locked`.
## Bulk Update Matches
Use `PATCH /tournaments/:tournament_id/matches/metadata` to update metadata for
multiple matches in one tournament.
At least one metadata field is required:
```json
{
"start_time": "18:00"
}
```
At least one selector is required:
- `match_ids`: explicit match ids in this tournament
- `group_stage_position`: all group stage matches at that match position
- `stage_level`: all playoff matches in that stage level
- `stage_id`: all matches in one stage
- `position`: all matches with that position
- `state`: all matches in that state, including `upcoming`
- `all: true`: every match in the tournament
Explicit ids take precedence over filters. Missing ids outside the tournament
return `404 Not Found`.
Examples:
```json
{
"match_ids": [1, 2, 5],
"start_time": "17:00"
}
```
```json
{
"group_stage_position": 1,
"start_time": "18:00"
}
```
```json
{
"stage_level": 3,
"location": "Finals Arena",
"start_time": "21:00",
"notes": "Playoff wave"
}
```
Owner authentication is required. Read-only follower tournaments reject this
with `423 Locked`.
## Follower Sync
`location`, `start_time`, and `notes` are part of tournament sync snapshots.
Leader updates enqueue follower sync and live tournament broadcasts. Followers
import these fields from the leader and remain read-only until takeover.

View File

@ -109,6 +109,43 @@ module TurniereE2E
result(session: session, tournament: tournament, checkpoints: checkpoints, stop_at: stop_at) result(session: session, tournament: tournament, checkpoints: checkpoints, stop_at: stop_at)
end end
def run_group_stage_playoff_recreation(stop_at: :third_playoffs_created)
session = register_session!
tournament = create_tournament!(
create_group_stage_payload(group_count: 2, teams_per_group: 2, playoff_teams_amount: 2)
)
checkpoints = {
created: summarize_tournament(tournament)
}
rounds = playoff_recreation_rounds
created_keys = %i[first_playoffs_created second_playoffs_created third_playoffs_created]
deleted_keys = %i[first_playoffs_deleted second_playoffs_deleted]
rounds.each_with_index do |round, index|
tournament = fetch_tournament!(tournament.fetch(:id))
apply_group_score_round!(tournament, round.fetch(:scores_by_team_name))
finish_stage!(group_stage_of!(fetch_tournament!(tournament.fetch(:id))).fetch(:id))
tournament = fetch_tournament!(tournament.fetch(:id))
created_key = created_keys.fetch(index)
checkpoints[created_key] = summarize_tournament(tournament).merge(
playoff_matchups: playoff_matchups(tournament)
)
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) def run_playoff_only(team_count:, stop_at: :completed)
session = register_session! session = register_session!
tournament = create_tournament!(create_playoff_only_payload(team_count: team_count)) tournament = create_tournament!(create_playoff_only_payload(team_count: team_count))
@ -433,6 +470,12 @@ module TurniereE2E
response.fetch(:json) response.fetch(:json)
end end
def delete_playoffs!(tournament_id)
response = client.delete("/tournaments/#{tournament_id}/playoffs")
expect_status!(response, 200, "delete playoffs for tournament #{tournament_id}")
response.fetch(:json)
end
def group_stage_of!(tournament) def group_stage_of!(tournament)
tournament.fetch(:stages).find { |stage| stage.fetch(:level) == -1 } || raise('group stage not found') tournament.fetch(:stages).find { |stage| stage.fetch(:level) == -1 } || raise('group stage not found')
end end
@ -487,6 +530,34 @@ module TurniereE2E
end end
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) def preferred_playoff_winner_name(match)
match.fetch(:match_scores) match.fetch(:match_scores)
.map { |score| score.dig(:team, :name) } .map { |score| score.dig(:team, :name) }
@ -521,6 +592,35 @@ module TurniereE2E
} }
end end
def playoff_recreation_rounds
[
{
scores_by_team_name: {
'G1-S1' => 3,
'G1-S2' => 1,
'G2-S1' => 3,
'G2-S2' => 1
}
},
{
scores_by_team_name: {
'G1-S1' => 0,
'G1-S2' => 4,
'G2-S1' => 0,
'G2-S2' => 4
}
},
{
scores_by_team_name: {
'G1-S1' => 5,
'G1-S2' => 2,
'G2-S1' => 2,
'G2-S2' => 5
}
}
]
end
def create_playoff_only_payload(team_count:) def create_playoff_only_payload(team_count:)
{ {
name: "HTTP E2E Playoff Only #{team_count} #{SecureRandom.hex(3)}", name: "HTTP E2E Playoff Only #{team_count} #{SecureRandom.hex(3)}",
@ -611,6 +711,19 @@ module TurniereE2E
} }
end end
def playoff_matchups(tournament)
entry_stage = tournament.fetch(:stages)
.reject { |stage| stage.fetch(:level) == -1 }
.max_by { |stage| stage.fetch(:level) }
entry_stage.fetch(:matches)
.sort_by { |match| match.fetch(:position) }
.map do |match|
match.fetch(:match_scores)
.map { |score| score.dig(:team, :name) }
.sort
end
end
def fetch_tournament_profile!(tournament_id) def fetch_tournament_profile!(tournament_id)
started_at = Process.clock_gettime(Process::CLOCK_MONOTONIC) started_at = Process.clock_gettime(Process::CLOCK_MONOTONIC)
response = get_response!("/tournaments/#{tournament_id}", params: { profile: 'true' }) response = get_response!("/tournaments/#{tournament_id}", params: { profile: 'true' })

View File

@ -46,6 +46,8 @@ result = case scenario
runner.run_group_stage_main_usecase(stop_at: options[:stop_at] || :final_finish_attempted) runner.run_group_stage_main_usecase(stop_at: options[:stop_at] || :final_finish_attempted)
when 'group_stage_intermediate_round' when 'group_stage_intermediate_round'
runner.run_group_stage_intermediate_round(stop_at: options[:stop_at] || :final_finish_attempted) runner.run_group_stage_intermediate_round(stop_at: options[:stop_at] || :final_finish_attempted)
when 'group_stage_playoff_recreation'
runner.run_group_stage_playoff_recreation(stop_at: options[:stop_at] || :third_playoffs_created)
when 'playoff_only' when 'playoff_only'
runner.run_playoff_only(team_count: options[:team_count], stop_at: options[:stop_at] || :completed) runner.run_playoff_only(team_count: options[:team_count], stop_at: options[:stop_at] || :completed)
when 'group_stage_render_profile' when 'group_stage_render_profile'

View File

@ -47,7 +47,7 @@ RSpec.describe MatchesController, type: :controller do
get :index, params: { tournament_id: @tournament.to_param } get :index, params: { tournament_id: @tournament.to_param }
match = deserialize_response(response).first match = deserialize_response(response).first
expect(match.keys).to match_array(%i[id position state teams match_scores group stage]) expect(match.keys).to match_array(%i[id position state location start_time notes teams match_scores group stage])
expect(match[:teams]).to all(include(:id, :name)) expect(match[:teams]).to all(include(:id, :name))
end end
@ -196,6 +196,102 @@ RSpec.describe MatchesController, type: :controller do
end end
end end
describe 'PATCH #metadata' do
let(:group_stage_tournament) { create(:group_stage_tournament, group_count: 2, match_factory: :filled_group_match) }
context 'as owner' do
before do
apply_authentication_headers_for group_stage_tournament.owner
allow(TournamentSyncEnqueue).to receive(:call)
allow(TournamentLiveBroadcast).to receive(:call)
end
it 'updates selected matches by explicit ids' do
selected_matches = group_stage_tournament.matches.first(2)
patch :metadata, params: {
tournament_id: group_stage_tournament.to_param,
match_ids: selected_matches.map(&:id),
location: 'Court A',
start_time: '17:00',
notes: 'Bring player cards'
}
expect(response).to be_successful
expect(selected_matches.map { |match| match.reload.location }).to all(eq('Court A'))
expect(selected_matches.map { |match| match.reload.start_time }).to all(eq('17:00'))
expect(selected_matches.map { |match| match.reload.notes }).to all(eq('Bring player cards'))
expect(deserialize_response(response).map { |match| match[:id] }).to eq(selected_matches.map(&:id))
expect(TournamentSyncEnqueue).to have_received(:call).with(group_stage_tournament)
expect(TournamentLiveBroadcast).to have_received(:call).with(group_stage_tournament.id)
end
it 'updates group stage matches by position filter' do
patch :metadata, params: {
tournament_id: group_stage_tournament.to_param,
group_stage_position: 1,
start_time: '18:00'
}
expect(response).to be_successful
group_stage_tournament.matches.each do |match|
expected_start_time = match.group_match? && match.position == 1 ? '18:00' : nil
expect(match.reload.start_time).to eq(expected_start_time)
end
end
it 'updates playoff matches by stage level filter' do
playoff_tournament = create(:stage_tournament, stage_count: 3, user: group_stage_tournament.owner)
target_stage = playoff_tournament.stages.find_by!(level: 3)
patch :metadata, params: {
tournament_id: playoff_tournament.to_param,
stage_level: target_stage.level,
start_time: '21:00'
}
expect(response).to be_successful
playoff_tournament.matches.each do |match|
expected_start_time = match.stage == target_stage ? '21:00' : nil
expect(match.reload.start_time).to eq(expected_start_time)
end
end
it 'requires a selector' do
patch :metadata, params: { tournament_id: group_stage_tournament.to_param, start_time: '17:00' }
expect(response).to have_http_status(:bad_request)
expect(deserialize_response(response)[:error]).to eq('Select matches with match_ids, filters, or all=true')
end
it 'rejects matches outside the tournament' do
other_match = create(:match)
patch :metadata, params: {
tournament_id: group_stage_tournament.to_param,
match_ids: [other_match.id],
start_time: '17:00'
}
expect(response).to have_http_status(:not_found)
expect(deserialize_response(response)[:error]).to eq("Matches not found in tournament: #{other_match.id}")
end
it 'rejects read only follower tournaments' do
group_stage_tournament.update!(read_only_mode: true, sync_auth_token: 'shared-secret')
patch :metadata, params: {
tournament_id: group_stage_tournament.to_param,
match_ids: [group_stage_tournament.matches.first.id],
start_time: '17:00'
}
expect(response).to have_http_status(:locked)
expect(deserialize_response(response)[:error]).to eq('Tournament is in read only follower mode')
end
end
end
describe 'POST #update' do describe 'POST #update' do
context 'on a running playoff match' do context 'on a running playoff match' do
let(:valid_update) do let(:valid_update) do
@ -237,6 +333,29 @@ RSpec.describe MatchesController, type: :controller do
expect(body[:state]).to eq(valid_update[:state]) expect(body[:state]).to eq(valid_update[:state])
end end
it 'updates metadata without changing match state' do
expect(TournamentLiveBroadcast).to receive(:call).with(@match.tournament.id)
put :update, params: {
id: @match.to_param,
location: 'Main Hall',
start_time: '17:00',
notes: 'Stream setup needed'
}
expect(response).to be_successful
@match.reload
expect(@match.state).to eq('not_started')
expect(@match.location).to eq('Main Hall')
expect(@match.start_time).to eq('17:00')
expect(@match.notes).to eq('Stream setup needed')
expect(deserialize_response(response)).to include(
location: 'Main Hall',
start_time: '17:00',
notes: 'Stream setup needed'
)
end
it 'writes a transaction log entry for the state change' do it 'writes a transaction log entry for the state change' do
expect(TournamentLiveBroadcast).to receive(:call).with(@match.tournament.id) expect(TournamentLiveBroadcast).to receive(:call).with(@match.tournament.id)

View File

@ -105,6 +105,7 @@ RSpec.describe TournamentsController, type: :controller do
expected_keys = %i[ expected_keys = %i[
id name code public description playoff_teams_amount id name code public description playoff_teams_amount
instant_finalists_amount intermediate_round_participants_amount instant_finalists_amount intermediate_round_participants_amount
location starts_at ends_at website_url
timestamp timer_mode timer_reason timer_reason_text 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.keys).to match_array(expected_keys)
@ -454,6 +455,25 @@ RSpec.describe TournamentsController, type: :controller do
end.to change(Tournament, :count).by(1) end.to change(Tournament, :count).by(1)
end end
it 'stores tournament metadata' do
starts_at = '2030-06-01T10:00:00+02:00'
ends_at = '2030-06-01T18:00:00+02:00'
post :create, params: create_playoff_tournament_data.merge(
location: 'Stadtpark Halle',
starts_at: starts_at,
ends_at: ends_at,
website_url: 'https://example.com/tournaments/summer-cup'
)
expect(response).to have_http_status(:created)
tournament = Tournament.find(deserialize_response(response)[:id])
expect(tournament.location).to eq('Stadtpark Halle')
expect(tournament.starts_at.iso8601).to eq(Time.iso8601(starts_at).utc.iso8601)
expect(tournament.ends_at.iso8601).to eq(Time.iso8601(ends_at).utc.iso8601)
expect(tournament.website_url).to eq('https://example.com/tournaments/summer-cup')
end
it 'associates the new tournament with the authenticated user' do it 'associates the new tournament with the authenticated user' do
expect do expect do
post :create, params: create_playoff_tournament_data post :create, params: create_playoff_tournament_data
@ -657,6 +677,43 @@ RSpec.describe TournamentsController, type: :controller do
expect(@tournament.name).to eq(valid_update[:name]) expect(@tournament.name).to eq(valid_update[:name])
end end
it 'updates tournament metadata' do
starts_at = '2030-06-01T10:00:00+02:00'
ends_at = '2030-06-01T18:00:00+02:00'
put :update, params: {
id: @tournament.to_param,
location: 'Arena Nord',
starts_at: starts_at,
ends_at: ends_at,
website_url: 'https://example.com/events/arena-nord'
}
expect(response).to have_http_status(:ok)
@tournament.reload
json = deserialize_response(response)
expect(@tournament.location).to eq('Arena Nord')
expect(@tournament.starts_at.iso8601).to eq(Time.iso8601(starts_at).utc.iso8601)
expect(@tournament.ends_at.iso8601).to eq(Time.iso8601(ends_at).utc.iso8601)
expect(@tournament.website_url).to eq('https://example.com/events/arena-nord')
expect(json[:starts_at]).to eq(@tournament.starts_at.iso8601)
expect(json[:ends_at]).to eq(@tournament.ends_at.iso8601)
end
it 'rejects invalid tournament metadata' do
put :update, params: {
id: @tournament.to_param,
starts_at: '2030-06-01T18:00:00+02:00',
ends_at: '2030-06-01T10:00:00+02:00',
website_url: 'notaurl'
}
expect(response).to have_http_status(:unprocessable_content)
json = deserialize_response(response)
expect(json[:ends_at]).to include('must be after starts_at')
expect(json[:website_url]).to include('is invalid')
end
it 'renders a JSON response with the tournament' do it 'renders a JSON response with the tournament' do
put :update, params: { id: @tournament.to_param }.merge(valid_update) put :update, params: { id: @tournament.to_param }.merge(valid_update)
expect(response).to have_http_status(:ok) expect(response).to have_http_status(:ok)
@ -864,6 +921,119 @@ RSpec.describe TournamentsController, type: :controller do
end end
end end
describe 'DELETE #destroy_playoffs' do
context 'without authentication headers' do
it 'renders an unauthorized error response' do
tournament = create_group_stage_with_unplayed_playoffs
delete :destroy_playoffs, params: { id: tournament.to_param }
expect(response).to have_http_status(:unauthorized)
end
end
context 'as owner' do
before do
apply_authentication_headers_for tournament.owner
allow(TournamentLiveBroadcast).to receive(:call)
end
let(:tournament) { create_group_stage_with_unplayed_playoffs }
it 'deletes unplayed playoff stages and reopens the group stage' do
expect do
delete :destroy_playoffs, params: { id: tournament.to_param }
end.to change { tournament.reload.stages.where('level >= 0').count }.from(1).to(0)
expect(response).to have_http_status(:ok)
expect(tournament.group_stage.reload).to be_in_progress
expect(deserialize_response(response).fetch(:stages).map { |stage| stage.fetch(:level) }).to eq([-1])
end
it 'pushes sync and live updates after deletion' do
expect(TournamentSyncEnqueue).to receive(:call).with(tournament)
expect(TournamentLiveBroadcast).to receive(:call).with(tournament.id)
delete :destroy_playoffs, params: { id: tournament.to_param }
expect(response).to have_http_status(:ok)
end
it 'rejects deletion after a playoff match has started' do
playoff_match = tournament.stages.find { |stage| stage.level >= 0 }.matches.first
playoff_match.update!(state: :in_progress)
expect do
delete :destroy_playoffs, params: { id: tournament.to_param }
end.not_to(change { tournament.reload.stages.where('level >= 0').count })
expect(response).to have_http_status(:unprocessable_content)
expect(deserialize_response(response)[:error]).to eq(
'Playoffs cannot be deleted after matches have started or scores changed'
)
expect(tournament.group_stage.reload).to be_finished
end
it 'rejects deletion after a playoff score has changed' do
playoff_match = tournament.stages.find { |stage| stage.level >= 0 }.matches.first
playoff_match.match_scores.first.update!(points: 1)
delete :destroy_playoffs, params: { id: tournament.to_param }
expect(response).to have_http_status(:unprocessable_content)
expect(deserialize_response(response)[:error]).to eq(
'Playoffs cannot be deleted after matches have started or scores changed'
)
expect(tournament.reload.stages.where('level >= 0').count).to eq(1)
end
it 'rejects deletion when no group stage exists' do
playoff_only = create(:stage_tournament, stage_count: 1)
apply_authentication_headers_for playoff_only.owner
delete :destroy_playoffs, params: { id: playoff_only.to_param }
expect(response).to have_http_status(:unprocessable_content)
expect(deserialize_response(response)[:error]).to eq('Only group-stage playoffs can be deleted')
end
it 'blocks deletion while tournament is read only' do
tournament.update!(read_only_mode: true, sync_auth_token: 'shared-secret')
delete :destroy_playoffs, params: { id: tournament.to_param }
expect(response).to have_http_status(:locked)
end
end
context 'as another user' do
it 'renders a forbidden error response' do
tournament = create_group_stage_with_unplayed_playoffs
apply_authentication_headers_for create(:user)
delete :destroy_playoffs, params: { id: tournament.to_param }
expect(response).to have_http_status(:forbidden)
end
end
def create_group_stage_with_unplayed_playoffs
tournament = create(
:group_stage_tournament,
stage_count: 0,
group_count: 2,
teams_count: 4,
playoff_teams_amount: 2,
instant_finalists_amount: 2,
intermediate_round_participants_amount: 0
)
tournament.group_stage.update!(state: :finished)
result = AddPlayoffsToTournamentAndSave.call(tournament: tournament, teams: tournament.teams.take(2))
expect(result).to be_a_success
tournament.reload
end
end
describe 'PATCH #sync_state' do describe 'PATCH #sync_state' do
before do before do
@tournament.update!(read_only_mode: true, sync_auth_token: 'shared-secret') @tournament.update!(read_only_mode: true, sync_auth_token: 'shared-secret')
@ -880,6 +1050,10 @@ RSpec.describe TournamentsController, type: :controller do
code: 'abc123', code: 'abc123',
description: 'Synced description', description: 'Synced description',
public: true, public: true,
location: 'Synced Arena',
starts_at: '2030-06-01T10:00:00+02:00',
ends_at: '2030-06-01T18:00:00+02:00',
website_url: 'https://example.com/synced',
playoff_teams_amount: 4, playoff_teams_amount: 4,
instant_finalists_amount: 4, instant_finalists_amount: 4,
intermediate_round_participants_amount: 0, intermediate_round_participants_amount: 0,
@ -897,6 +1071,10 @@ RSpec.describe TournamentsController, type: :controller do
@tournament.reload @tournament.reload
expect(@tournament.sync_source_tournament_id).to eq(123) expect(@tournament.sync_source_tournament_id).to eq(123)
expect(@tournament.name).to eq('Synced Tournament') expect(@tournament.name).to eq('Synced Tournament')
expect(@tournament.location).to eq('Synced Arena')
expect(@tournament.starts_at.iso8601).to eq(Time.iso8601('2030-06-01T10:00:00+02:00').utc.iso8601)
expect(@tournament.ends_at.iso8601).to eq(Time.iso8601('2030-06-01T18:00:00+02:00').utc.iso8601)
expect(@tournament.website_url).to eq('https://example.com/synced')
expect(@tournament.teams.pluck(:name)).to eq(['Alpha']) expect(@tournament.teams.pluck(:name)).to eq(['Alpha'])
expect(deserialize_response(response)).not_to have_key(:sync_auth_token) expect(deserialize_response(response)).not_to have_key(:sync_auth_token)
expect(deserialize_response(response)).not_to have_key(:sync_auth_token_digest) expect(deserialize_response(response)).not_to have_key(:sync_auth_token_digest)

View File

@ -82,6 +82,10 @@ RSpec.describe 'Backend API surface HTTP E2E' do
expect(full_show[:status]).to eq(200) expect(full_show[:status]).to eq(200)
expect(full_show.dig(:json, :stages)).to be_an(Array) expect(full_show.dig(:json, :stages)).to be_an(Array)
expect(full_show.dig(:json, :teams)).to be_an(Array) expect(full_show.dig(:json, :teams)).to be_an(Array)
expect(full_show.dig(:json, :location)).to start_with('Venue')
expect(full_show.dig(:json, :starts_at)).to eq('2030-06-01T08:00:00Z')
expect(full_show.dig(:json, :ends_at)).to eq('2030-06-01T16:00:00Z')
expect(full_show.dig(:json, :website_url)).to eq('https://example.com/turniere-e2e')
expect(invalid_index[:status]).to eq(400) expect(invalid_index[:status]).to eq(400)
expect(invalid_index.dig(:json, :error)).to eq('invalid type') expect(invalid_index.dig(:json, :error)).to eq('invalid type')
@ -183,6 +187,10 @@ RSpec.describe 'Backend API surface HTTP E2E' do
update_response = owner_client.patch("/tournaments/#{tournament.fetch(:id)}", body: { update_response = owner_client.patch("/tournaments/#{tournament.fetch(:id)}", body: {
name: "Renamed #{unique_suffix}", name: "Renamed #{unique_suffix}",
location: 'Updated E2E Arena',
starts_at: '2030-07-01T10:00:00+02:00',
ends_at: '2030-07-01T18:00:00+02:00',
website_url: 'https://example.com/updated-turniere-e2e',
playoff_teams_amount: 8, playoff_teams_amount: 8,
instant_finalists_amount: 8, instant_finalists_amount: 8,
intermediate_round_participants_amount: 0 intermediate_round_participants_amount: 0
@ -243,6 +251,10 @@ RSpec.describe 'Backend API surface HTTP E2E' do
expect(update_response[:status]).to eq(200) expect(update_response[:status]).to eq(200)
expect(update_response.dig(:json, :name)).to start_with('Renamed') expect(update_response.dig(:json, :name)).to start_with('Renamed')
expect(update_response.dig(:json, :location)).to eq('Updated E2E Arena')
expect(update_response.dig(:json, :starts_at)).to eq('2030-07-01T08:00:00Z')
expect(update_response.dig(:json, :ends_at)).to eq('2030-07-01T16:00:00Z')
expect(update_response.dig(:json, :website_url)).to eq('https://example.com/updated-turniere-e2e')
expect(auto_recalc_response[:status]).to eq(200) expect(auto_recalc_response[:status]).to eq(200)
expect(auto_recalc_response.dig(:json, :playoff_teams_amount)).to eq(4) expect(auto_recalc_response.dig(:json, :playoff_teams_amount)).to eq(4)
@ -592,6 +604,10 @@ RSpec.describe 'Backend API surface HTTP E2E' do
name: "#{name_prefix} #{unique_suffix}", name: "#{name_prefix} #{unique_suffix}",
description: 'HTTP API surface E2E group-stage tournament', description: 'HTTP API surface E2E group-stage tournament',
public: public, public: public,
location: "Venue #{name_prefix}",
starts_at: '2030-06-01T10:00:00+02:00',
ends_at: '2030-06-01T18:00:00+02:00',
website_url: 'https://example.com/turniere-e2e',
group_stage: true, group_stage: true,
playoff_teams_amount: 8, playoff_teams_amount: 8,
teams: 4.times.flat_map do |group_index| teams: 4.times.flat_map do |group_index|
@ -654,6 +670,10 @@ RSpec.describe 'Backend API surface HTTP E2E' do
name: "#{name_prefix} #{unique_suffix}", name: "#{name_prefix} #{unique_suffix}",
description: 'HTTP API surface E2E playoff tournament', description: 'HTTP API surface E2E playoff tournament',
public: public, public: public,
location: "Venue #{name_prefix}",
starts_at: '2030-06-01T10:00:00+02:00',
ends_at: '2030-06-01T18:00:00+02:00',
website_url: 'https://example.com/turniere-e2e',
teams: teams || 4.times.map do |index| teams: teams || 4.times.map do |index|
{ name: "#{name_prefix.tr(' ', '')}-S#{index + 1}" } { name: "#{name_prefix.tr(' ', '')}-S#{index + 1}" }
end end

View File

@ -50,6 +50,51 @@ RSpec.describe 'Tournament follower sync HTTP E2E' do
expect(tournament_signature(follower)).to eq(tournament_signature(source)) expect(tournament_signature(follower)).to eq(tournament_signature(source))
expect(synced_team_id_pairs(source: source, follower: follower)).not_to be_empty expect(synced_team_id_pairs(source: source, follower: follower)).not_to be_empty
log_phase!('update match metadata and verify follower mirrors it')
source_group_stage = source.fetch(:stages).find { |stage| stage.fetch(:level) == -1 }
first_source_group = source_group_stage.fetch(:groups).sort_by { |group| group.fetch(:number) }.first
first_group_matches = first_source_group.fetch(:matches).sort_by { |match| match.fetch(:position) }
single_metadata = source_owner_client.patch("/matches/#{first_group_matches.fetch(0).fetch(:id)}", body: {
location: 'Main Hall',
start_time: '17:00',
notes: 'Opening match'
})
explicit_bulk_metadata = source_owner_client.patch("/tournaments/#{source.fetch(:id)}/matches/metadata", body: {
match_ids: first_group_matches.values_at(1, 2).map { |match| match.fetch(:id) },
location: 'Court B',
start_time: '17:30'
})
group_position_metadata = source_owner_client.patch("/tournaments/#{source.fetch(:id)}/matches/metadata", body: {
group_stage_position: 3,
start_time: '18:00',
notes: 'Group position 3 wave'
})
expect(single_metadata[:status]).to eq(200)
expect(single_metadata.dig(:json, :location)).to eq('Main Hall')
expect(explicit_bulk_metadata[:status]).to eq(200)
expect(explicit_bulk_metadata[:json].map { |match| match.fetch(:location) }).to all(eq('Court B'))
expect(group_position_metadata[:status]).to eq(200)
expect(group_position_metadata[:json].map { |match| match.fetch(:start_time) }).to all(eq('18:00'))
source = fetch_tournament(client: source_anonymous_client, tournament_id: source.fetch(:id))
follower = wait_for_tournament_sync!(source_tournament_id: source.fetch(:id), follower_tournament_id: follower.fetch(:id))
expect(tournament_signature(follower)).to eq(tournament_signature(source))
follower_group_stage = follower.fetch(:stages).find { |stage| stage.fetch(:level) == -1 }
follower_group = follower_group_stage.fetch(:groups).sort_by { |group| group.fetch(:number) }.first
follower_first_match = follower_group.fetch(:matches).sort_by { |match| match.fetch(:position) }.first
locked_match_metadata = follower_owner_client.patch("/matches/#{follower_first_match.fetch(:id)}", body: {
location: 'Follower edit'
})
locked_bulk_metadata = follower_owner_client.patch("/tournaments/#{follower.fetch(:id)}/matches/metadata", body: {
match_ids: [follower_first_match.fetch(:id)],
start_time: '19:00'
})
expect(locked_match_metadata[:status]).to eq(423)
expect(locked_match_metadata.dig(:json, :error)).to eq('Tournament is in read only follower mode')
expect(locked_bulk_metadata[:status]).to eq(423)
expect(locked_bulk_metadata.dig(:json, :error)).to eq('Tournament is in read only follower mode')
log_phase!('swap matches on leader and verify follower behavior') log_phase!('swap matches on leader and verify follower behavior')
source_group_stage = source.fetch(:stages).find { |stage| stage.fetch(:level) == -1 } source_group_stage = source.fetch(:stages).find { |stage| stage.fetch(:level) == -1 }
first_source_group = source_group_stage.fetch(:groups).sort_by { |group| group.fetch(:number) }.first first_source_group = source_group_stage.fetch(:groups).sort_by { |group| group.fetch(:number) }.first
@ -111,6 +156,10 @@ RSpec.describe 'Tournament follower sync HTTP E2E' do
log_phase!('tighten playoff cutoff and rename teams') log_phase!('tighten playoff cutoff and rename teams')
update_cutoff = source_owner_client.patch("/tournaments/#{source.fetch(:id)}", body: { update_cutoff = source_owner_client.patch("/tournaments/#{source.fetch(:id)}", body: {
location: 'Source Updated Arena',
starts_at: '2030-07-01T10:00:00+02:00',
ends_at: '2030-07-01T18:00:00+02:00',
website_url: 'https://example.com/updated-sync-source',
playoff_teams_amount: 4, playoff_teams_amount: 4,
instant_finalists_amount: 4, instant_finalists_amount: 4,
intermediate_round_participants_amount: 0 intermediate_round_participants_amount: 0
@ -144,6 +193,20 @@ RSpec.describe 'Tournament follower sync HTTP E2E' do
expect(source.fetch(:team_action_lists)).to eq([]) expect(source.fetch(:team_action_lists)).to eq([])
expect(follower.fetch(:team_action_lists)).to eq([]) expect(follower.fetch(:team_action_lists)).to eq([])
log_phase!('bulk update playoff match metadata and verify follower mirrors it')
source_playoff_stage = source.fetch(:stages).select { |stage| stage.fetch(:level) >= 0 }.max_by { |stage| stage.fetch(:level) }
playoff_metadata = source_owner_client.patch("/tournaments/#{source.fetch(:id)}/matches/metadata", body: {
stage_level: source_playoff_stage.fetch(:level),
location: 'Finals Arena',
start_time: '21:00',
notes: 'Playoff wave'
})
expect(playoff_metadata[:status]).to eq(200)
expect(playoff_metadata[:json].map { |match| match.fetch(:location) }).to all(eq('Finals Arena'))
source = fetch_tournament(client: source_anonymous_client, tournament_id: source.fetch(:id))
follower = wait_for_tournament_sync!(source_tournament_id: source.fetch(:id), follower_tournament_id: follower.fetch(:id))
expect(tournament_signature(follower)).to eq(tournament_signature(source))
log_phase!('create team action list on source and verify follower mirrors it') log_phase!('create team action list on source and verify follower mirrors it')
created_list = create_team_action_list!(source.fetch(:id)) created_list = create_team_action_list!(source.fetch(:id))
expect(created_list.fetch(:key)).to eq(playoff_token_list_key) expect(created_list.fetch(:key)).to eq(playoff_token_list_key)
@ -214,6 +277,64 @@ RSpec.describe 'Tournament follower sync HTTP E2E' do
expect(takeover_update[:status]).to eq(200) expect(takeover_update[:status]).to eq(200)
end end
it 'mirrors repeated playoff deletion and recreation into the follower tournament' do
log_phase!('create small source and follower tournaments')
source = create_group_stage_tournament(
client: source_owner_client,
public: false,
name_prefix: 'Sync Playoff Delete Source',
group_count: 2,
teams_per_group: 2,
playoff_teams_amount: 2
)
follower = create_follower_tournament(client: follower_owner_client, public: true, name_prefix: 'Sync Playoff Delete Follower')
configure_sync = source_owner_client.patch("/tournaments/#{source.fetch(:id)}", body: {
sync_target_url: sync_state_url(follower.fetch(:id)),
sync_auth_token: sync_token
})
expect(configure_sync[:status]).to eq(200)
follower = wait_for_tournament_sync!(source_tournament_id: source.fetch(:id), follower_tournament_id: follower.fetch(:id))
expect(tournament_signature(follower)).to eq(tournament_signature(source))
rounds = playoff_recreation_rounds
rounds.each_with_index do |round, index|
log_phase!("create playoffs round #{index + 1}")
apply_group_score_round!(
source_tournament_id: source.fetch(:id),
scores_by_team_name: round.fetch(:scores_by_team_name)
)
source = fetch_tournament(client: source_anonymous_client, tournament_id: source.fetch(:id))
group_stage = source.fetch(:stages).find { |stage| stage.fetch(:level) == -1 }
finish_stage = source_owner_client.patch("/stages/#{group_stage.fetch(:id)}", body: { state: 'finished' })
expect(finish_stage[:status]).to eq(200)
source = fetch_tournament(client: source_anonymous_client, tournament_id: source.fetch(:id))
follower = wait_for_tournament_sync!(source_tournament_id: source.fetch(:id), follower_tournament_id: follower.fetch(:id))
expect(playoff_matchups(source)).to eq(round.fetch(:expected_playoff_matchups))
expect(playoff_matchups(follower)).to eq(round.fetch(:expected_playoff_matchups))
expect(tournament_signature(follower)).to eq(tournament_signature(source))
next if index == rounds.size - 1
locked_delete = follower_owner_client.delete("/tournaments/#{follower.fetch(:id)}/playoffs")
expect(locked_delete[:status]).to eq(423)
expect(locked_delete.dig(:json, :error)).to eq('Tournament is in read only follower mode')
log_phase!("delete playoffs round #{index + 1}")
delete_playoffs = source_owner_client.delete("/tournaments/#{source.fetch(:id)}/playoffs")
expect(delete_playoffs[:status]).to eq(200)
source = fetch_tournament(client: source_anonymous_client, tournament_id: source.fetch(:id))
follower = wait_for_tournament_sync!(source_tournament_id: source.fetch(:id), follower_tournament_id: follower.fetch(:id))
expect(source.fetch(:stages).map { |stage| stage.fetch(:level) }).to eq([-1])
expect(follower.fetch(:stages).map { |stage| stage.fetch(:level) }).to eq([-1])
expect(source.fetch(:stages).first.fetch(:state)).to eq('in_progress')
expect(follower.fetch(:stages).first.fetch(:state)).to eq('in_progress')
expect(tournament_signature(follower)).to eq(tournament_signature(source))
end
end
def log_phase!(message) def log_phase!(message)
puts "phase #{message}" puts "phase #{message}"
$stdout.flush $stdout.flush
@ -228,15 +349,22 @@ RSpec.describe 'Tournament follower sync HTTP E2E' do
client client
end end
def create_group_stage_tournament(client:, public:, name_prefix:) def create_group_stage_tournament(client:, public:, name_prefix:, **options)
group_count = options.fetch(:group_count, 4)
teams_per_group = options.fetch(:teams_per_group, 4)
playoff_teams_amount = options.fetch(:playoff_teams_amount, 8)
payload = { payload = {
name: "#{name_prefix} #{unique_suffix}", name: "#{name_prefix} #{unique_suffix}",
description: 'Follower sync source tournament', description: 'Follower sync source tournament',
public: public, public: public,
location: "Source Venue #{name_prefix}",
starts_at: '2030-06-01T10:00:00+02:00',
ends_at: '2030-06-01T18:00:00+02:00',
website_url: 'https://example.com/sync-source',
group_stage: true, group_stage: true,
playoff_teams_amount: 8, playoff_teams_amount: playoff_teams_amount,
teams: 4.times.flat_map do |group_index| teams: group_count.times.flat_map do |group_index|
4.times.map do |seed_index| teams_per_group.times.map do |seed_index|
{ {
name: "#{name_prefix.tr(' ', '')}-G#{group_index + 1}-S#{seed_index + 1}", name: "#{name_prefix.tr(' ', '')}-G#{group_index + 1}-S#{seed_index + 1}",
group: group_index group: group_index
@ -250,11 +378,19 @@ RSpec.describe 'Tournament follower sync HTTP E2E' do
fetch_tournament(client: client, tournament_id: response.dig(:json, :id)) fetch_tournament(client: client, tournament_id: response.dig(:json, :id))
end end
def sync_state_url(follower_id)
"#{follower_base_url}/tournaments/#{follower_id}/sync_state"
end
def create_playoff_tournament(client:, public:, name_prefix:) def create_playoff_tournament(client:, public:, name_prefix:)
payload = { payload = {
name: "#{name_prefix} #{unique_suffix}", name: "#{name_prefix} #{unique_suffix}",
description: 'Unrelated playoff tournament', description: 'Unrelated playoff tournament',
public: public, public: public,
location: "Noise Venue #{name_prefix}",
starts_at: '2030-06-01T10:00:00+02:00',
ends_at: '2030-06-01T18:00:00+02:00',
website_url: 'https://example.com/noise',
teams: 4.times.map do |index| teams: 4.times.map do |index|
{ name: "#{name_prefix.tr(' ', '')}-S#{index + 1}" } { name: "#{name_prefix.tr(' ', '')}-S#{index + 1}" }
end end
@ -270,6 +406,10 @@ RSpec.describe 'Tournament follower sync HTTP E2E' do
name: "#{name_prefix} #{unique_suffix}", name: "#{name_prefix} #{unique_suffix}",
description: 'Follower tournament', description: 'Follower tournament',
public: public, public: public,
location: "Follower Venue #{name_prefix}",
starts_at: '2030-05-01T10:00:00+02:00',
ends_at: '2030-05-01T18:00:00+02:00',
website_url: 'https://example.com/follower',
read_only_mode: true, read_only_mode: true,
sync_auth_token: sync_token sync_auth_token: sync_token
}) })
@ -460,6 +600,97 @@ RSpec.describe 'Tournament follower sync HTTP E2E' do
play_match!(match_id: match.fetch(:id), first_score: score_a, second_score: score_b) play_match!(match_id: match.fetch(:id), first_score: score_a, second_score: score_b)
end end
def playoff_recreation_rounds
[
{
scores_by_team_name: {
'G1-S1' => 3,
'G1-S2' => 1,
'G2-S1' => 3,
'G2-S2' => 1
},
expected_playoff_matchups: [
%w[G1-S1 G2-S2],
%w[G1-S2 G2-S1]
]
},
{
scores_by_team_name: {
'G1-S1' => 0,
'G1-S2' => 4,
'G2-S1' => 0,
'G2-S2' => 4
},
expected_playoff_matchups: [
%w[G1-S2 G2-S1],
%w[G1-S1 G2-S2]
]
},
{
scores_by_team_name: {
'G1-S1' => 5,
'G1-S2' => 2,
'G2-S1' => 2,
'G2-S2' => 5
},
expected_playoff_matchups: [
%w[G1-S1 G2-S1],
%w[G1-S2 G2-S2]
]
}
]
end
def apply_group_score_round!(source_tournament_id:, scores_by_team_name:)
tournament = fetch_tournament(client: source_anonymous_client, tournament_id: source_tournament_id)
group_stage = tournament.fetch(:stages).find { |stage| stage.fetch(:level) == -1 }
group_stage.fetch(:groups).each do |group|
group.fetch(:matches).each do |match|
if match.fetch(:state) == 'not_started'
start_response = source_owner_client.patch("/matches/#{match.fetch(:id)}", body: { state: 'in_progress' })
expect(start_response[:status]).to eq(200)
end
update_match_points_by_seed!(match.fetch(:id), scores_by_team_name)
next if match.fetch(:state) == 'finished'
finish_response = source_owner_client.patch("/matches/#{match.fetch(:id)}", body: { state: 'finished' })
expect(finish_response[:status]).to eq(200)
end
end
end
def update_match_points_by_seed!(match_id, scores_by_team_name)
match = source_anonymous_client.get("/matches/#{match_id}")
expect(match[:status]).to eq(200)
match.fetch(:json).fetch(:match_scores).each do |score|
response = source_owner_client.patch("/match_scores/#{score.fetch(:id)}", body: {
points: scores_by_team_name.fetch(seed_label(score.dig(:team, :name)))
})
expect(response[:status]).to eq(200)
end
end
def playoff_matchups(tournament)
entry_stage = tournament.fetch(:stages)
.select { |stage| stage.fetch(:level) >= 0 }
.max_by { |stage| stage.fetch(:level) }
entry_stage.fetch(:matches)
.sort_by { |match| match.fetch(:position) }
.map do |match|
match.fetch(:match_scores)
.map { |score| seed_label(score.dig(:team, :name)) }
.sort
end
end
def seed_label(team_name)
team_name[/G\d+-S\d+\z/] || team_name
end
def finish_playoff_bracket!(source_id:) def finish_playoff_bracket!(source_id:)
loop do loop do
tournament = fetch_tournament(client: source_anonymous_client, tournament_id: source_id) tournament = fetch_tournament(client: source_anonymous_client, tournament_id: source_id)
@ -500,6 +731,10 @@ RSpec.describe 'Tournament follower sync HTTP E2E' do
{ {
name: tournament.fetch(:name), name: tournament.fetch(:name),
public: tournament.fetch(:public), public: tournament.fetch(:public),
location: tournament[:location],
starts_at: tournament[:starts_at],
ends_at: tournament[:ends_at],
website_url: tournament[:website_url],
playoff_teams_amount: tournament.fetch(:playoff_teams_amount), playoff_teams_amount: tournament.fetch(:playoff_teams_amount),
instant_finalists_amount: tournament.fetch(:instant_finalists_amount), instant_finalists_amount: tournament.fetch(:instant_finalists_amount),
intermediate_round_participants_amount: tournament.fetch(:intermediate_round_participants_amount), intermediate_round_participants_amount: tournament.fetch(:intermediate_round_participants_amount),
@ -548,6 +783,9 @@ RSpec.describe 'Tournament follower sync HTTP E2E' do
state: match.fetch(:state), state: match.fetch(:state),
position: match.fetch(:position), position: match.fetch(:position),
decider: match.fetch(:decider), decider: match.fetch(:decider),
location: match[:location],
start_time: match[:start_time],
notes: match[:notes],
scores: match.fetch(:match_scores).map do |score| scores: match.fetch(:match_scores).map do |score|
{ {
team_name: score.dig(:team, :name), team_name: score.dig(:team, :name),

View File

@ -61,6 +61,27 @@ RSpec.describe 'Tournament lifecycle HTTP E2E' do
.find { |stage| stage[:level] == 0 }[:match_states]).to eq(%w[finished finished]) .find { |stage| stage[:level] == 0 }[:match_states]).to eq(%w[finished finished])
end end
it 'deletes and recreates group-stage playoffs over HTTP with changed group results' do
result = runner.run_group_stage_playoff_recreation
checkpoints = result.fetch(:checkpoints)
expect(checkpoints.fetch(:first_playoffs_created).fetch(:playoff_matchups)).to eq([
%w[G1-S1 G2-S2],
%w[G1-S2 G2-S1]
])
expect(checkpoints.fetch(:first_playoffs_deleted).fetch(:stage_summaries).map { |stage| stage.fetch(:level) }).to eq([-1])
expect(checkpoints.fetch(:first_playoffs_deleted).fetch(:stage_summaries).first.fetch(:state)).to eq('in_progress')
expect(checkpoints.fetch(:second_playoffs_created).fetch(:playoff_matchups)).to eq([
%w[G1-S2 G2-S1],
%w[G1-S1 G2-S2]
])
expect(checkpoints.fetch(:second_playoffs_deleted).fetch(:stage_summaries).map { |stage| stage.fetch(:level) }).to eq([-1])
expect(checkpoints.fetch(:third_playoffs_created).fetch(:playoff_matchups)).to eq([
%w[G1-S1 G2-S1],
%w[G1-S2 G2-S2]
])
end
{ {
won: [1, 2], won: [1, 2],
lost: [2, 1], lost: [2, 1],

View File

@ -33,7 +33,11 @@ RSpec.describe 'Tournament sync test HTTP E2E' do
rename = client.patch("/tournaments/#{leader.fetch(:id)}", body: { rename = client.patch("/tournaments/#{leader.fetch(:id)}", body: {
name: "Leader Renamed #{SecureRandom.hex(3)}", name: "Leader Renamed #{SecureRandom.hex(3)}",
description: 'Roundtrip sync test' description: 'Roundtrip sync test',
location: 'Sync Test Arena',
starts_at: '2030-08-01T10:00:00+02:00',
ends_at: '2030-08-01T18:00:00+02:00',
website_url: 'https://example.com/sync-test'
}) })
expect(rename[:status]).to eq(200) expect(rename[:status]).to eq(200)
@ -52,6 +56,10 @@ RSpec.describe 'Tournament sync test HTTP E2E' do
expect(after_follower.fetch(:name)).to eq(after_leader.fetch(:name)) 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(:description)).to eq(after_leader.fetch(:description))
expect(after_follower.fetch(:location)).to eq(after_leader.fetch(:location))
expect(after_follower.fetch(:starts_at)).to eq(after_leader.fetch(:starts_at))
expect(after_follower.fetch(:ends_at)).to eq(after_leader.fetch(:ends_at))
expect(after_follower.fetch(:website_url)).to eq(after_leader.fetch(:website_url))
expect(after_follower.fetch(:teams).map { |team| team.fetch(:name) }) expect(after_follower.fetch(:teams).map { |team| team.fetch(:name) })
.to match_array(after_leader.fetch(:teams).map { |team| team.fetch(:name) }) .to match_array(after_leader.fetch(:teams).map { |team| team.fetch(:name) })
end end

View File

@ -15,5 +15,9 @@ RSpec.describe MatchesController, type: :routing do
it 'routes to #swap' do it 'routes to #swap' do
expect(patch: '/matches/1/swap').to route_to('matches#swap', id: '1') expect(patch: '/matches/1/swap').to route_to('matches#swap', id: '1')
end 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
end end

View File

@ -28,6 +28,10 @@ RSpec.describe TournamentsController, type: :routing do
expect(delete: '/tournaments/1').to route_to('tournaments#destroy', id: '1') expect(delete: '/tournaments/1').to route_to('tournaments#destroy', id: '1')
end 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 it 'routes to #sync_state' do
expect(patch: '/tournaments/1/sync_state').to route_to('tournaments#sync_state', id: '1') expect(patch: '/tournaments/1/sync_state').to route_to('tournaments#sync_state', id: '1')
end end

View File

@ -26,9 +26,14 @@ RSpec.describe 'Tournament live payloads' do
payload = described_class.build(tournament) payload = described_class.build(tournament)
serialized_match = payload.find { |candidate| candidate[:id] == match.id } 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[:state]).to eq(match.state)
expect(serialized_match[:position]).to eq(match.position) 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(:group, :id)).to eq(match.group_id)
expect(serialized_match.dig(:stage, :id)).to eq(match.group.stage_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)) expect(serialized_match[:teams].map { |team| team[:id] }).to match_array(match.teams.map(&:id))

View File

@ -7,11 +7,20 @@ RSpec.describe TournamentSnapshotImporter do
it 'replaces follower tournament graph using source ids as stable mapping keys' 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 = create(:group_stage_tournament, group_count: 2, match_factory: :filled_group_match)
source.update!( source.update!(
location: 'Court 4',
starts_at: Time.utc(2026, 4, 24, 9, 0, 0),
ends_at: Time.utc(2026, 4, 24, 18, 30, 0),
website_url: 'https://example.com/sync-cup',
timestamp: Time.utc(2026, 4, 24, 10, 15, 0), timestamp: Time.utc(2026, 4, 24, 10, 15, 0),
timer_mode: 'countdown', timer_mode: 'countdown',
timer_reason: 'custom', timer_reason: 'custom',
timer_reason_text: 'Opening ceremony' timer_reason_text: 'Opening ceremony'
) )
source.matches.first.update!(
location: 'Main Hall',
start_time: '17:00',
notes: 'Opening match'
)
source.beamers.create!( source.beamers.create!(
name: 'stream-a', name: 'stream-a',
display_state: 'during_game', display_state: 'during_game',
@ -28,6 +37,10 @@ RSpec.describe TournamentSnapshotImporter do
follower.reload follower.reload
expect(follower.sync_source_tournament_id).to eq(source.id) expect(follower.sync_source_tournament_id).to eq(source.id)
expect(follower.name).to eq(source.name) expect(follower.name).to eq(source.name)
expect(follower.location).to eq(source.location)
expect(follower.starts_at).to eq(source.starts_at)
expect(follower.ends_at).to eq(source.ends_at)
expect(follower.website_url).to eq(source.website_url)
expect(follower.timer_mode).to eq(source.timer_mode) expect(follower.timer_mode).to eq(source.timer_mode)
expect(follower.timer_reason).to eq(source.timer_reason) expect(follower.timer_reason).to eq(source.timer_reason)
expect(follower.timer_reason_text).to eq(source.timer_reason_text) expect(follower.timer_reason_text).to eq(source.timer_reason_text)
@ -37,6 +50,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.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.stages.count).to eq(source.stages.count)
expect(follower.matches.count).to eq(source.matches.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.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) }) expect(follower.beamers.pluck(:sync_source_id)).to match_array(snapshot.fetch(:beamers).map { |beamer| beamer.fetch(:id) })
end end
@ -136,6 +154,11 @@ RSpec.describe TournamentSnapshotImporter do
tournament.reload tournament.reload
play_some_playoff_matches!(tournament) 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!( tournament.update!(
timestamp: Time.utc(2026, 4, 24, 10, 15, 0), timestamp: Time.utc(2026, 4, 24, 10, 15, 0),
timer_mode: 'countdown', timer_mode: 'countdown',
@ -197,6 +220,10 @@ RSpec.describe TournamentSnapshotImporter do
{ {
name: tournament.name, name: tournament.name,
public: tournament.public, public: tournament.public,
location: tournament.location,
starts_at: tournament.starts_at&.iso8601,
ends_at: tournament.ends_at&.iso8601,
website_url: tournament.website_url,
playoff_teams_amount: tournament.playoff_teams_amount, playoff_teams_amount: tournament.playoff_teams_amount,
instant_finalists_amount: tournament.instant_finalists_amount, instant_finalists_amount: tournament.instant_finalists_amount,
intermediate_round_participants_amount: tournament.intermediate_round_participants_amount, intermediate_round_participants_amount: tournament.intermediate_round_participants_amount,
@ -254,6 +281,9 @@ RSpec.describe TournamentSnapshotImporter do
state: match.state, state: match.state,
position: match.position, position: match.position,
decider: match.decider, 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| scores: match.match_scores.includes(:team).sort_by { |score| score.team.name }.map do |score|
{ {
team_name: score.team.name, team_name: score.team.name,

View File

@ -758,9 +758,10 @@ def start_test_local(
@task @task
def test(c): def test(c, spec=""):
"""Run the normal RSpec suite.""" """Run the normal RSpec suite, or one or more spec files via --spec."""
_stream_subprocess(_rspec_command(), env=_env()) spec_paths = shlex.split(spec) if spec else []
_stream_subprocess(_rspec_command(*spec_paths), env=_env())
@task(name="test-shard") @task(name="test-shard")
@ -1048,6 +1049,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") @task(name="scenario-playoff")
def scenario_playoff( def scenario_playoff(
c, c,