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:
commit
27ed1e191c
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
@ -223,6 +247,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|
|
||||
|
|
@ -293,6 +330,7 @@ class TournamentsController < ApplicationController
|
|||
params.slice(
|
||||
:name, :description, :public, :teams, :group_stage, :playoff_teams_amount,
|
||||
:instant_finalists_amount, :intermediate_round_participants_amount,
|
||||
:location, :starts_at, :ends_at, :website_url,
|
||||
:read_only_mode, :sync_target_url, :sync_auth_token
|
||||
).permit!
|
||||
end
|
||||
|
|
|
|||
|
|
@ -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 website_url].freeze
|
||||
|
||||
belongs_to :user
|
||||
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_reason, inclusion: { in: TIMER_REASONS }, allow_nil: true
|
||||
validates :sync_target_url, format: URI::DEFAULT_PARSER.make_regexp(%w[http https]), allow_blank: true
|
||||
validates :website_url, format: URI::DEFAULT_PARSER.make_regexp(%w[http https]), allow_blank: true
|
||||
validate :sync_configuration_is_complete
|
||||
|
||||
validate :playoff_teams_amount_is_positive_power_of_two
|
||||
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 +158,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? ||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -1,5 +1,13 @@
|
|||
# frozen_string_literal: true
|
||||
|
||||
class SimpleTournamentSerializer < ApplicationSerializer
|
||||
attributes :name, :code, :public
|
||||
attributes :name, :code, :public, :location, :website_url
|
||||
|
||||
attribute :starts_at do
|
||||
object.starts_at&.iso8601
|
||||
end
|
||||
|
||||
attribute :ends_at do
|
||||
object.ends_at&.iso8601
|
||||
end
|
||||
end
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -6,6 +6,8 @@ class TournamentSnapshotImporter
|
|||
code
|
||||
description
|
||||
public
|
||||
location
|
||||
website_url
|
||||
playoff_teams_amount
|
||||
instant_finalists_amount
|
||||
intermediate_round_participants_amount
|
||||
|
|
@ -67,6 +69,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]
|
||||
|
|
@ -139,6 +143,9 @@ class TournamentSnapshotImporter
|
|||
match.state = attrs.fetch(:state)
|
||||
match.position = attrs[:position]
|
||||
match.decider = attrs[:decider]
|
||||
match.location = attrs[:location]
|
||||
match.start_time = attrs[:start_time]
|
||||
match.notes = attrs[:notes]
|
||||
end
|
||||
end
|
||||
|
||||
|
|
|
|||
|
|
@ -9,6 +9,10 @@ class TournamentSyncSchema
|
|||
code
|
||||
description
|
||||
public
|
||||
location
|
||||
starts_at
|
||||
ends_at
|
||||
website_url
|
||||
playoff_teams_amount
|
||||
instant_finalists_amount
|
||||
intermediate_round_participants_amount
|
||||
|
|
@ -48,7 +52,7 @@ class TournamentSyncSchema
|
|||
ignored: %w[created_at updated_at sync_source_id].freeze
|
||||
}.freeze,
|
||||
Match => {
|
||||
synced: %w[id state position stage_id group_id decider].freeze,
|
||||
synced: %w[id state position stage_id group_id decider location start_time notes].freeze,
|
||||
ignored: %w[created_at updated_at sync_source_id].freeze
|
||||
}.freeze,
|
||||
MatchScore => {
|
||||
|
|
|
|||
|
|
@ -25,7 +25,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
|
||||
|
|
@ -38,6 +42,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]
|
||||
|
|
|
|||
|
|
@ -0,0 +1,12 @@
|
|||
# frozen_string_literal: true
|
||||
|
||||
class AddMetadataToTournaments < ActiveRecord::Migration[7.0]
|
||||
def change
|
||||
change_table :tournaments, bulk: true do |t|
|
||||
t.string :location
|
||||
t.datetime :starts_at
|
||||
t.datetime :ends_at
|
||||
t.string :website_url
|
||||
end
|
||||
end
|
||||
end
|
||||
|
|
@ -0,0 +1,9 @@
|
|||
# frozen_string_literal: true
|
||||
|
||||
class AddMatchMetadataToMatches < ActiveRecord::Migration[7.0]
|
||||
def change
|
||||
add_column :matches, :location, :string
|
||||
add_column :matches, :start_time, :string
|
||||
add_column :matches, :notes, :text
|
||||
end
|
||||
end
|
||||
|
|
@ -74,8 +74,11 @@ ActiveRecord::Schema[8.1].define(version: 2026_05_04_120000) 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
|
||||
|
|
@ -175,12 +178,15 @@ ActiveRecord::Schema[8.1].define(version: 2026_05_04_120000) do
|
|||
t.string "code", null: false
|
||||
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.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"
|
||||
|
|
@ -194,6 +200,7 @@ ActiveRecord::Schema[8.1].define(version: 2026_05_04_120000) 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
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
@ -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_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)
|
||||
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,19 @@ module TurniereE2E
|
|||
}
|
||||
end
|
||||
|
||||
def playoff_matchups(tournament)
|
||||
entry_stage = tournament.fetch(:stages)
|
||||
.reject { |stage| stage.fetch(:level) == -1 }
|
||||
.max_by { |stage| stage.fetch(:level) }
|
||||
entry_stage.fetch(:matches)
|
||||
.sort_by { |match| match.fetch(:position) }
|
||||
.map do |match|
|
||||
match.fetch(:match_scores)
|
||||
.map { |score| score.dig(:team, :name) }
|
||||
.sort
|
||||
end
|
||||
end
|
||||
|
||||
def fetch_tournament_profile!(tournament_id)
|
||||
started_at = Process.clock_gettime(Process::CLOCK_MONOTONIC)
|
||||
response = get_response!("/tournaments/#{tournament_id}", params: { profile: 'true' })
|
||||
|
|
|
|||
|
|
@ -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'
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
||||
|
|
|
|||
|
|
@ -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 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)
|
||||
|
|
@ -454,6 +455,25 @@ RSpec.describe TournamentsController, type: :controller do
|
|||
end.to change(Tournament, :count).by(1)
|
||||
end
|
||||
|
||||
it 'stores tournament metadata' do
|
||||
starts_at = '2030-06-01T10:00:00+02:00'
|
||||
ends_at = '2030-06-01T18:00:00+02:00'
|
||||
|
||||
post :create, params: create_playoff_tournament_data.merge(
|
||||
location: 'Stadtpark Halle',
|
||||
starts_at: starts_at,
|
||||
ends_at: ends_at,
|
||||
website_url: 'https://example.com/tournaments/summer-cup'
|
||||
)
|
||||
|
||||
expect(response).to have_http_status(:created)
|
||||
tournament = Tournament.find(deserialize_response(response)[:id])
|
||||
expect(tournament.location).to eq('Stadtpark Halle')
|
||||
expect(tournament.starts_at.iso8601).to eq(Time.iso8601(starts_at).utc.iso8601)
|
||||
expect(tournament.ends_at.iso8601).to eq(Time.iso8601(ends_at).utc.iso8601)
|
||||
expect(tournament.website_url).to eq('https://example.com/tournaments/summer-cup')
|
||||
end
|
||||
|
||||
it 'associates the new tournament with the authenticated user' do
|
||||
expect do
|
||||
post :create, params: create_playoff_tournament_data
|
||||
|
|
@ -657,6 +677,43 @@ RSpec.describe TournamentsController, type: :controller do
|
|||
expect(@tournament.name).to eq(valid_update[:name])
|
||||
end
|
||||
|
||||
it 'updates tournament metadata' do
|
||||
starts_at = '2030-06-01T10:00:00+02:00'
|
||||
ends_at = '2030-06-01T18:00:00+02:00'
|
||||
|
||||
put :update, params: {
|
||||
id: @tournament.to_param,
|
||||
location: 'Arena Nord',
|
||||
starts_at: starts_at,
|
||||
ends_at: ends_at,
|
||||
website_url: 'https://example.com/events/arena-nord'
|
||||
}
|
||||
|
||||
expect(response).to have_http_status(:ok)
|
||||
@tournament.reload
|
||||
json = deserialize_response(response)
|
||||
expect(@tournament.location).to eq('Arena Nord')
|
||||
expect(@tournament.starts_at.iso8601).to eq(Time.iso8601(starts_at).utc.iso8601)
|
||||
expect(@tournament.ends_at.iso8601).to eq(Time.iso8601(ends_at).utc.iso8601)
|
||||
expect(@tournament.website_url).to eq('https://example.com/events/arena-nord')
|
||||
expect(json[:starts_at]).to eq(@tournament.starts_at.iso8601)
|
||||
expect(json[:ends_at]).to eq(@tournament.ends_at.iso8601)
|
||||
end
|
||||
|
||||
it 'rejects invalid tournament metadata' do
|
||||
put :update, params: {
|
||||
id: @tournament.to_param,
|
||||
starts_at: '2030-06-01T18:00:00+02:00',
|
||||
ends_at: '2030-06-01T10:00:00+02:00',
|
||||
website_url: 'notaurl'
|
||||
}
|
||||
|
||||
expect(response).to have_http_status(:unprocessable_content)
|
||||
json = deserialize_response(response)
|
||||
expect(json[:ends_at]).to include('must be after starts_at')
|
||||
expect(json[:website_url]).to include('is invalid')
|
||||
end
|
||||
|
||||
it 'renders a JSON response with the tournament' do
|
||||
put :update, params: { id: @tournament.to_param }.merge(valid_update)
|
||||
expect(response).to have_http_status(:ok)
|
||||
|
|
@ -864,6 +921,119 @@ RSpec.describe TournamentsController, type: :controller do
|
|||
end
|
||||
end
|
||||
|
||||
describe 'DELETE #destroy_playoffs' do
|
||||
context 'without authentication headers' do
|
||||
it 'renders an unauthorized error response' do
|
||||
tournament = create_group_stage_with_unplayed_playoffs
|
||||
|
||||
delete :destroy_playoffs, params: { id: tournament.to_param }
|
||||
|
||||
expect(response).to have_http_status(:unauthorized)
|
||||
end
|
||||
end
|
||||
|
||||
context 'as owner' do
|
||||
before do
|
||||
apply_authentication_headers_for tournament.owner
|
||||
allow(TournamentLiveBroadcast).to receive(:call)
|
||||
end
|
||||
|
||||
let(:tournament) { create_group_stage_with_unplayed_playoffs }
|
||||
|
||||
it 'deletes unplayed playoff stages and reopens the group stage' do
|
||||
expect do
|
||||
delete :destroy_playoffs, params: { id: tournament.to_param }
|
||||
end.to change { tournament.reload.stages.where('level >= 0').count }.from(1).to(0)
|
||||
|
||||
expect(response).to have_http_status(:ok)
|
||||
expect(tournament.group_stage.reload).to be_in_progress
|
||||
expect(deserialize_response(response).fetch(:stages).map { |stage| stage.fetch(:level) }).to eq([-1])
|
||||
end
|
||||
|
||||
it 'pushes sync and live updates after deletion' do
|
||||
expect(TournamentSyncEnqueue).to receive(:call).with(tournament)
|
||||
expect(TournamentLiveBroadcast).to receive(:call).with(tournament.id)
|
||||
|
||||
delete :destroy_playoffs, params: { id: tournament.to_param }
|
||||
|
||||
expect(response).to have_http_status(:ok)
|
||||
end
|
||||
|
||||
it 'rejects deletion after a playoff match has started' do
|
||||
playoff_match = tournament.stages.find { |stage| stage.level >= 0 }.matches.first
|
||||
playoff_match.update!(state: :in_progress)
|
||||
|
||||
expect do
|
||||
delete :destroy_playoffs, params: { id: tournament.to_param }
|
||||
end.not_to(change { tournament.reload.stages.where('level >= 0').count })
|
||||
|
||||
expect(response).to have_http_status(:unprocessable_content)
|
||||
expect(deserialize_response(response)[:error]).to eq(
|
||||
'Playoffs cannot be deleted after matches have started or scores changed'
|
||||
)
|
||||
expect(tournament.group_stage.reload).to be_finished
|
||||
end
|
||||
|
||||
it 'rejects deletion after a playoff score has changed' do
|
||||
playoff_match = tournament.stages.find { |stage| stage.level >= 0 }.matches.first
|
||||
playoff_match.match_scores.first.update!(points: 1)
|
||||
|
||||
delete :destroy_playoffs, params: { id: tournament.to_param }
|
||||
|
||||
expect(response).to have_http_status(:unprocessable_content)
|
||||
expect(deserialize_response(response)[:error]).to eq(
|
||||
'Playoffs cannot be deleted after matches have started or scores changed'
|
||||
)
|
||||
expect(tournament.reload.stages.where('level >= 0').count).to eq(1)
|
||||
end
|
||||
|
||||
it 'rejects deletion when no group stage exists' do
|
||||
playoff_only = create(:stage_tournament, stage_count: 1)
|
||||
apply_authentication_headers_for playoff_only.owner
|
||||
|
||||
delete :destroy_playoffs, params: { id: playoff_only.to_param }
|
||||
|
||||
expect(response).to have_http_status(:unprocessable_content)
|
||||
expect(deserialize_response(response)[:error]).to eq('Only group-stage playoffs can be deleted')
|
||||
end
|
||||
|
||||
it 'blocks deletion while tournament is read only' do
|
||||
tournament.update!(read_only_mode: true, sync_auth_token: 'shared-secret')
|
||||
|
||||
delete :destroy_playoffs, params: { id: tournament.to_param }
|
||||
|
||||
expect(response).to have_http_status(:locked)
|
||||
end
|
||||
end
|
||||
|
||||
context 'as another user' do
|
||||
it 'renders a forbidden error response' do
|
||||
tournament = create_group_stage_with_unplayed_playoffs
|
||||
apply_authentication_headers_for create(:user)
|
||||
|
||||
delete :destroy_playoffs, params: { id: tournament.to_param }
|
||||
|
||||
expect(response).to have_http_status(:forbidden)
|
||||
end
|
||||
end
|
||||
|
||||
def create_group_stage_with_unplayed_playoffs
|
||||
tournament = create(
|
||||
:group_stage_tournament,
|
||||
stage_count: 0,
|
||||
group_count: 2,
|
||||
teams_count: 4,
|
||||
playoff_teams_amount: 2,
|
||||
instant_finalists_amount: 2,
|
||||
intermediate_round_participants_amount: 0
|
||||
)
|
||||
tournament.group_stage.update!(state: :finished)
|
||||
result = AddPlayoffsToTournamentAndSave.call(tournament: tournament, teams: tournament.teams.take(2))
|
||||
expect(result).to be_a_success
|
||||
tournament.reload
|
||||
end
|
||||
end
|
||||
|
||||
describe 'PATCH #sync_state' do
|
||||
before do
|
||||
@tournament.update!(read_only_mode: true, sync_auth_token: 'shared-secret')
|
||||
|
|
@ -880,6 +1050,10 @@ RSpec.describe TournamentsController, type: :controller do
|
|||
code: 'abc123',
|
||||
description: 'Synced description',
|
||||
public: true,
|
||||
location: 'Synced Arena',
|
||||
starts_at: '2030-06-01T10:00:00+02:00',
|
||||
ends_at: '2030-06-01T18:00:00+02:00',
|
||||
website_url: 'https://example.com/synced',
|
||||
playoff_teams_amount: 4,
|
||||
instant_finalists_amount: 4,
|
||||
intermediate_round_participants_amount: 0,
|
||||
|
|
@ -897,6 +1071,10 @@ RSpec.describe TournamentsController, type: :controller do
|
|||
@tournament.reload
|
||||
expect(@tournament.sync_source_tournament_id).to eq(123)
|
||||
expect(@tournament.name).to eq('Synced Tournament')
|
||||
expect(@tournament.location).to eq('Synced Arena')
|
||||
expect(@tournament.starts_at.iso8601).to eq(Time.iso8601('2030-06-01T10:00:00+02:00').utc.iso8601)
|
||||
expect(@tournament.ends_at.iso8601).to eq(Time.iso8601('2030-06-01T18:00:00+02:00').utc.iso8601)
|
||||
expect(@tournament.website_url).to eq('https://example.com/synced')
|
||||
expect(@tournament.teams.pluck(:name)).to eq(['Alpha'])
|
||||
expect(deserialize_response(response)).not_to have_key(:sync_auth_token)
|
||||
expect(deserialize_response(response)).not_to have_key(:sync_auth_token_digest)
|
||||
|
|
|
|||
|
|
@ -82,6 +82,10 @@ RSpec.describe 'Backend API surface HTTP E2E' do
|
|||
expect(full_show[:status]).to eq(200)
|
||||
expect(full_show.dig(:json, :stages)).to be_an(Array)
|
||||
expect(full_show.dig(:json, :teams)).to be_an(Array)
|
||||
expect(full_show.dig(:json, :location)).to start_with('Venue')
|
||||
expect(full_show.dig(:json, :starts_at)).to eq('2030-06-01T08:00:00Z')
|
||||
expect(full_show.dig(:json, :ends_at)).to eq('2030-06-01T16:00:00Z')
|
||||
expect(full_show.dig(:json, :website_url)).to eq('https://example.com/turniere-e2e')
|
||||
|
||||
expect(invalid_index[:status]).to eq(400)
|
||||
expect(invalid_index.dig(:json, :error)).to eq('invalid type')
|
||||
|
|
@ -183,6 +187,10 @@ 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',
|
||||
website_url: 'https://example.com/updated-turniere-e2e',
|
||||
playoff_teams_amount: 8,
|
||||
instant_finalists_amount: 8,
|
||||
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.dig(:json, :name)).to start_with('Renamed')
|
||||
expect(update_response.dig(:json, :location)).to eq('Updated E2E Arena')
|
||||
expect(update_response.dig(:json, :starts_at)).to eq('2030-07-01T08:00:00Z')
|
||||
expect(update_response.dig(:json, :ends_at)).to eq('2030-07-01T16:00:00Z')
|
||||
expect(update_response.dig(:json, :website_url)).to eq('https://example.com/updated-turniere-e2e')
|
||||
|
||||
expect(auto_recalc_response[:status]).to eq(200)
|
||||
expect(auto_recalc_response.dig(:json, :playoff_teams_amount)).to eq(4)
|
||||
|
|
@ -592,6 +604,10 @@ 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',
|
||||
website_url: 'https://example.com/turniere-e2e',
|
||||
group_stage: true,
|
||||
playoff_teams_amount: 8,
|
||||
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}",
|
||||
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',
|
||||
website_url: 'https://example.com/turniere-e2e',
|
||||
teams: teams || 4.times.map do |index|
|
||||
{ name: "#{name_prefix.tr(' ', '')}-S#{index + 1}" }
|
||||
end
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
@ -111,6 +156,10 @@ 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',
|
||||
website_url: 'https://example.com/updated-sync-source',
|
||||
playoff_teams_amount: 4,
|
||||
instant_finalists_amount: 4,
|
||||
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(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)
|
||||
|
|
@ -214,6 +277,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_matchups(source)).to eq(round.fetch(:expected_playoff_matchups))
|
||||
expect(playoff_matchups(follower)).to eq(round.fetch(:expected_playoff_matchups))
|
||||
expect(tournament_signature(follower)).to eq(tournament_signature(source))
|
||||
|
||||
next if index == rounds.size - 1
|
||||
|
||||
locked_delete = follower_owner_client.delete("/tournaments/#{follower.fetch(:id)}/playoffs")
|
||||
expect(locked_delete[:status]).to eq(423)
|
||||
expect(locked_delete.dig(:json, :error)).to eq('Tournament is in read only follower mode')
|
||||
|
||||
log_phase!("delete playoffs round #{index + 1}")
|
||||
delete_playoffs = source_owner_client.delete("/tournaments/#{source.fetch(:id)}/playoffs")
|
||||
expect(delete_playoffs[:status]).to eq(200)
|
||||
|
||||
source = fetch_tournament(client: source_anonymous_client, tournament_id: source.fetch(:id))
|
||||
follower = wait_for_tournament_sync!(source_tournament_id: source.fetch(:id), follower_tournament_id: follower.fetch(:id))
|
||||
expect(source.fetch(:stages).map { |stage| stage.fetch(:level) }).to eq([-1])
|
||||
expect(follower.fetch(:stages).map { |stage| stage.fetch(:level) }).to eq([-1])
|
||||
expect(source.fetch(:stages).first.fetch(:state)).to eq('in_progress')
|
||||
expect(follower.fetch(:stages).first.fetch(:state)).to eq('in_progress')
|
||||
expect(tournament_signature(follower)).to eq(tournament_signature(source))
|
||||
end
|
||||
end
|
||||
|
||||
def log_phase!(message)
|
||||
puts "phase #{message}"
|
||||
$stdout.flush
|
||||
|
|
@ -228,15 +349,22 @@ 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,
|
||||
location: "Source Venue #{name_prefix}",
|
||||
starts_at: '2030-06-01T10:00:00+02:00',
|
||||
ends_at: '2030-06-01T18:00:00+02:00',
|
||||
website_url: 'https://example.com/sync-source',
|
||||
group_stage: true,
|
||||
playoff_teams_amount: 8,
|
||||
teams: 4.times.flat_map do |group_index|
|
||||
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,11 +378,19 @@ 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}",
|
||||
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',
|
||||
website_url: 'https://example.com/noise',
|
||||
teams: 4.times.map do |index|
|
||||
{ name: "#{name_prefix.tr(' ', '')}-S#{index + 1}" }
|
||||
end
|
||||
|
|
@ -270,6 +406,10 @@ 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',
|
||||
website_url: 'https://example.com/follower',
|
||||
read_only_mode: true,
|
||||
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)
|
||||
end
|
||||
|
||||
def playoff_recreation_rounds
|
||||
[
|
||||
{
|
||||
scores_by_team_name: {
|
||||
'G1-S1' => 3,
|
||||
'G1-S2' => 1,
|
||||
'G2-S1' => 3,
|
||||
'G2-S2' => 1
|
||||
},
|
||||
expected_playoff_matchups: [
|
||||
%w[G1-S1 G2-S2],
|
||||
%w[G1-S2 G2-S1]
|
||||
]
|
||||
},
|
||||
{
|
||||
scores_by_team_name: {
|
||||
'G1-S1' => 0,
|
||||
'G1-S2' => 4,
|
||||
'G2-S1' => 0,
|
||||
'G2-S2' => 4
|
||||
},
|
||||
expected_playoff_matchups: [
|
||||
%w[G1-S2 G2-S1],
|
||||
%w[G1-S1 G2-S2]
|
||||
]
|
||||
},
|
||||
{
|
||||
scores_by_team_name: {
|
||||
'G1-S1' => 5,
|
||||
'G1-S2' => 2,
|
||||
'G2-S1' => 2,
|
||||
'G2-S2' => 5
|
||||
},
|
||||
expected_playoff_matchups: [
|
||||
%w[G1-S1 G2-S1],
|
||||
%w[G1-S2 G2-S2]
|
||||
]
|
||||
}
|
||||
]
|
||||
end
|
||||
|
||||
def apply_group_score_round!(source_tournament_id:, scores_by_team_name:)
|
||||
tournament = fetch_tournament(client: source_anonymous_client, tournament_id: source_tournament_id)
|
||||
group_stage = tournament.fetch(:stages).find { |stage| stage.fetch(:level) == -1 }
|
||||
|
||||
group_stage.fetch(:groups).each do |group|
|
||||
group.fetch(:matches).each do |match|
|
||||
if match.fetch(:state) == 'not_started'
|
||||
start_response = source_owner_client.patch("/matches/#{match.fetch(:id)}", body: { state: 'in_progress' })
|
||||
expect(start_response[:status]).to eq(200)
|
||||
end
|
||||
|
||||
update_match_points_by_seed!(match.fetch(:id), scores_by_team_name)
|
||||
|
||||
next if match.fetch(:state) == 'finished'
|
||||
|
||||
finish_response = source_owner_client.patch("/matches/#{match.fetch(:id)}", body: { state: 'finished' })
|
||||
expect(finish_response[:status]).to eq(200)
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
def update_match_points_by_seed!(match_id, scores_by_team_name)
|
||||
match = source_anonymous_client.get("/matches/#{match_id}")
|
||||
expect(match[:status]).to eq(200)
|
||||
|
||||
match.fetch(:json).fetch(:match_scores).each do |score|
|
||||
response = source_owner_client.patch("/match_scores/#{score.fetch(:id)}", body: {
|
||||
points: scores_by_team_name.fetch(seed_label(score.dig(:team, :name)))
|
||||
})
|
||||
expect(response[:status]).to eq(200)
|
||||
end
|
||||
end
|
||||
|
||||
def playoff_matchups(tournament)
|
||||
entry_stage = tournament.fetch(:stages)
|
||||
.select { |stage| stage.fetch(:level) >= 0 }
|
||||
.max_by { |stage| stage.fetch(:level) }
|
||||
entry_stage.fetch(:matches)
|
||||
.sort_by { |match| match.fetch(:position) }
|
||||
.map do |match|
|
||||
match.fetch(:match_scores)
|
||||
.map { |score| seed_label(score.dig(:team, :name)) }
|
||||
.sort
|
||||
end
|
||||
end
|
||||
|
||||
def seed_label(team_name)
|
||||
team_name[/G\d+-S\d+\z/] || team_name
|
||||
end
|
||||
|
||||
def finish_playoff_bracket!(source_id:)
|
||||
loop do
|
||||
tournament = fetch_tournament(client: source_anonymous_client, tournament_id: source_id)
|
||||
|
|
@ -500,6 +731,10 @@ RSpec.describe 'Tournament follower sync HTTP E2E' do
|
|||
{
|
||||
name: tournament.fetch(:name),
|
||||
public: tournament.fetch(:public),
|
||||
location: tournament[:location],
|
||||
starts_at: tournament[:starts_at],
|
||||
ends_at: tournament[:ends_at],
|
||||
website_url: tournament[:website_url],
|
||||
playoff_teams_amount: tournament.fetch(:playoff_teams_amount),
|
||||
instant_finalists_amount: tournament.fetch(:instant_finalists_amount),
|
||||
intermediate_round_participants_amount: tournament.fetch(:intermediate_round_participants_amount),
|
||||
|
|
@ -548,6 +783,9 @@ RSpec.describe 'Tournament follower sync HTTP E2E' do
|
|||
state: match.fetch(:state),
|
||||
position: match.fetch(:position),
|
||||
decider: match.fetch(:decider),
|
||||
location: match[:location],
|
||||
start_time: match[:start_time],
|
||||
notes: match[:notes],
|
||||
scores: match.fetch(:match_scores).map do |score|
|
||||
{
|
||||
team_name: score.dig(:team, :name),
|
||||
|
|
|
|||
|
|
@ -61,6 +61,27 @@ RSpec.describe 'Tournament lifecycle HTTP E2E' do
|
|||
.find { |stage| stage[:level] == 0 }[:match_states]).to eq(%w[finished finished])
|
||||
end
|
||||
|
||||
it 'deletes and recreates group-stage playoffs over HTTP with changed group results' do
|
||||
result = runner.run_group_stage_playoff_recreation
|
||||
checkpoints = result.fetch(:checkpoints)
|
||||
|
||||
expect(checkpoints.fetch(:first_playoffs_created).fetch(:playoff_matchups)).to eq([
|
||||
%w[G1-S1 G2-S2],
|
||||
%w[G1-S2 G2-S1]
|
||||
])
|
||||
expect(checkpoints.fetch(:first_playoffs_deleted).fetch(:stage_summaries).map { |stage| stage.fetch(:level) }).to eq([-1])
|
||||
expect(checkpoints.fetch(:first_playoffs_deleted).fetch(:stage_summaries).first.fetch(:state)).to eq('in_progress')
|
||||
expect(checkpoints.fetch(:second_playoffs_created).fetch(:playoff_matchups)).to eq([
|
||||
%w[G1-S2 G2-S1],
|
||||
%w[G1-S1 G2-S2]
|
||||
])
|
||||
expect(checkpoints.fetch(:second_playoffs_deleted).fetch(:stage_summaries).map { |stage| stage.fetch(:level) }).to eq([-1])
|
||||
expect(checkpoints.fetch(:third_playoffs_created).fetch(:playoff_matchups)).to eq([
|
||||
%w[G1-S1 G2-S1],
|
||||
%w[G1-S2 G2-S2]
|
||||
])
|
||||
end
|
||||
|
||||
{
|
||||
won: [1, 2],
|
||||
lost: [2, 1],
|
||||
|
|
|
|||
|
|
@ -33,7 +33,11 @@ RSpec.describe 'Tournament sync test HTTP E2E' do
|
|||
|
||||
rename = client.patch("/tournaments/#{leader.fetch(:id)}", body: {
|
||||
name: "Leader Renamed #{SecureRandom.hex(3)}",
|
||||
description: 'Roundtrip sync test'
|
||||
description: 'Roundtrip sync test',
|
||||
location: 'Sync Test Arena',
|
||||
starts_at: '2030-08-01T10:00:00+02:00',
|
||||
ends_at: '2030-08-01T18:00:00+02:00',
|
||||
website_url: 'https://example.com/sync-test'
|
||||
})
|
||||
expect(rename[:status]).to eq(200)
|
||||
|
||||
|
|
@ -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(:description)).to eq(after_leader.fetch(:description))
|
||||
expect(after_follower.fetch(:location)).to eq(after_leader.fetch(:location))
|
||||
expect(after_follower.fetch(:starts_at)).to eq(after_leader.fetch(:starts_at))
|
||||
expect(after_follower.fetch(:ends_at)).to eq(after_leader.fetch(:ends_at))
|
||||
expect(after_follower.fetch(:website_url)).to eq(after_leader.fetch(:website_url))
|
||||
expect(after_follower.fetch(:teams).map { |team| team.fetch(:name) })
|
||||
.to match_array(after_leader.fetch(:teams).map { |team| team.fetch(:name) })
|
||||
end
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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))
|
||||
|
|
|
|||
|
|
@ -7,11 +7,20 @@ 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),
|
||||
website_url: 'https://example.com/sync-cup',
|
||||
timestamp: Time.utc(2026, 4, 24, 10, 15, 0),
|
||||
timer_mode: 'countdown',
|
||||
timer_reason: 'custom',
|
||||
timer_reason_text: 'Opening ceremony'
|
||||
)
|
||||
source.matches.first.update!(
|
||||
location: 'Main Hall',
|
||||
start_time: '17:00',
|
||||
notes: 'Opening match'
|
||||
)
|
||||
source.beamers.create!(
|
||||
name: 'stream-a',
|
||||
display_state: 'during_game',
|
||||
|
|
@ -28,6 +37,10 @@ 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.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)
|
||||
|
|
@ -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.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 +154,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',
|
||||
|
|
@ -197,6 +220,10 @@ 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,
|
||||
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,
|
||||
|
|
@ -254,6 +281,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,
|
||||
|
|
|
|||
29
tasks.py
29
tasks.py
|
|
@ -758,9 +758,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")
|
||||
|
|
@ -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")
|
||||
def scenario_playoff(
|
||||
c,
|
||||
|
|
|
|||
Loading…
Reference in New Issue