Add read-only tournament follower sync
This commit is contained in:
parent
798eb56657
commit
133ebae847
|
|
@ -84,3 +84,56 @@ rails e2e spec:
|
|||
script:
|
||||
- cd /app
|
||||
- inv blackbox-service
|
||||
|
||||
rails follow sync e2e spec:
|
||||
stage: test
|
||||
image: $CI_REGISTRY_IMAGE/test/commits:$CI_COMMIT_SHA
|
||||
services:
|
||||
- name: postgres:16
|
||||
alias: source-postgres
|
||||
- name: postgres:16
|
||||
alias: follower-postgres
|
||||
- name: $CI_REGISTRY_IMAGE/production/commits:$CI_COMMIT_SHA
|
||||
alias: source-app
|
||||
command:
|
||||
- bundle
|
||||
- exec
|
||||
- rails
|
||||
- s
|
||||
- -p
|
||||
- "3000"
|
||||
- -b
|
||||
- 0.0.0.0
|
||||
- name: $CI_REGISTRY_IMAGE/production/commits:$CI_COMMIT_SHA
|
||||
alias: follower-app
|
||||
command:
|
||||
- bundle
|
||||
- exec
|
||||
- rails
|
||||
- s
|
||||
- -p
|
||||
- "3000"
|
||||
- -b
|
||||
- 0.0.0.0
|
||||
variables:
|
||||
FF_NETWORK_PER_BUILD: "true"
|
||||
NO_PROXY: source-app,follower-app,source-postgres,follower-postgres,localhost,127.0.0.1
|
||||
no_proxy: source-app,follower-app,source-postgres,follower-postgres,localhost,127.0.0.1
|
||||
TURNIERE_SOURCE_E2E_BASE_URL: http://source-app:3000
|
||||
TURNIERE_FOLLOWER_E2E_BASE_URL: http://follower-app:3000
|
||||
TURNIERE_E2E_EMAIL: e2e@example.com
|
||||
TURNIERE_E2E_PASSWORD: password123
|
||||
TURNIERE_E2E_USERNAME: e2e-user
|
||||
rules:
|
||||
- if: $SKIP_TEST
|
||||
when: never
|
||||
- if: $SKIP_ALL_SPECS
|
||||
when: never
|
||||
- if: $SKIP_E2E_SPEC
|
||||
when: never
|
||||
- if: $CI_PIPELINE_SOURCE != "push"
|
||||
when: never
|
||||
- when: always
|
||||
script:
|
||||
- cd /app
|
||||
- inv blackbox-follow-service
|
||||
|
|
|
|||
|
|
@ -30,6 +30,16 @@ class ApplicationController < ActionController::API
|
|||
}, status: :forbidden
|
||||
end
|
||||
|
||||
def render_read_only_error
|
||||
render json: {
|
||||
error: 'Tournament is in read only follower mode'
|
||||
}, status: :locked
|
||||
end
|
||||
|
||||
def require_writable_tournament!(tournament)
|
||||
render_read_only_error if tournament.read_only_mode?
|
||||
end
|
||||
|
||||
def render_not_found_error(exception)
|
||||
render json: { error: exception.to_s }, status: :not_found
|
||||
end
|
||||
|
|
|
|||
|
|
@ -3,6 +3,7 @@
|
|||
class BetsController < ApplicationController
|
||||
before_action :set_match, only: %i[index create]
|
||||
before_action :authenticate_user!, only: %i[create]
|
||||
before_action -> { require_writable_tournament!(@match.tournament) }, only: %i[create]
|
||||
rescue_from UserServiceError, with: :handle_user_service_error
|
||||
|
||||
def index
|
||||
|
|
|
|||
|
|
@ -4,6 +4,7 @@ class MatchScoresController < ApplicationController
|
|||
before_action :set_match_score, only: %i[show update]
|
||||
before_action :authenticate_user!, only: %i[update]
|
||||
before_action -> { require_owner! @match_score.owner }, only: %i[update]
|
||||
before_action -> { require_writable_tournament!(@match_score.tournament) }, only: %i[update]
|
||||
|
||||
# GET /scores/1
|
||||
def show
|
||||
|
|
@ -14,6 +15,7 @@ class MatchScoresController < ApplicationController
|
|||
def update
|
||||
if @match_score.update(match_score_params)
|
||||
UpdateGroupsGroupScoresAndSave.call(group: @match_score.match.group) if @match_score.part_of_group_match?
|
||||
push_sync_if_needed!(@match_score.tournament)
|
||||
render json: @match_score
|
||||
else
|
||||
render json: @match_score.errors, status: :unprocessable_entity
|
||||
|
|
@ -31,4 +33,10 @@ class MatchScoresController < ApplicationController
|
|||
def match_score_params
|
||||
params.slice(:points, :hidden_points).permit!
|
||||
end
|
||||
|
||||
def push_sync_if_needed!(tournament)
|
||||
TournamentSyncPusher.push!(tournament)
|
||||
rescue TournamentSyncPusher::SyncFailed => e
|
||||
logger.warn("Tournament sync push failed for #{tournament.id}: #{e.message}")
|
||||
end
|
||||
end
|
||||
|
|
|
|||
|
|
@ -7,6 +7,8 @@ class MatchesController < ApplicationController
|
|||
before_action -> { require_owner! @match.owner }, only: %i[update]
|
||||
before_action -> { require_owner! @group.owner }, only: %i[create]
|
||||
before_action :set_tournament, only: %i[index]
|
||||
before_action -> { require_writable_tournament!(@match.tournament) }, only: %i[update]
|
||||
before_action -> { require_writable_tournament!(@group.tournament) }, only: %i[create]
|
||||
|
||||
# GET/tournaments/1/matches
|
||||
def index
|
||||
|
|
@ -54,6 +56,7 @@ class MatchesController < ApplicationController
|
|||
render json: match.errors, status: :unprocessable_entity
|
||||
elsif match.save
|
||||
UpdateGroupsGroupScoresAndSave.call(group: @group)
|
||||
push_sync_if_needed!(@group.tournament)
|
||||
render json: match, include: %w[match_scores.team group], status: :created
|
||||
else
|
||||
render json: match.errors, status: :unprocessable_entity
|
||||
|
|
@ -75,6 +78,7 @@ class MatchesController < ApplicationController
|
|||
end
|
||||
|
||||
render json: @match
|
||||
push_sync_if_needed!(@match.tournament)
|
||||
else
|
||||
render json: @match.errors, status: :unprocessable_entity
|
||||
raise ActiveRecord::Rollback
|
||||
|
|
@ -156,4 +160,10 @@ class MatchesController < ApplicationController
|
|||
end
|
||||
end
|
||||
end
|
||||
|
||||
def push_sync_if_needed!(tournament)
|
||||
TournamentSyncPusher.push!(tournament)
|
||||
rescue TournamentSyncPusher::SyncFailed => e
|
||||
logger.warn("Tournament sync push failed for #{tournament.id}: #{e.message}")
|
||||
end
|
||||
end
|
||||
|
|
|
|||
|
|
@ -4,6 +4,7 @@ class StagesController < ApplicationController
|
|||
before_action :set_stage, only: %i[show update]
|
||||
before_action :authenticate_user!, only: %i[update]
|
||||
before_action -> { require_owner! @stage.owner }, only: %i[update]
|
||||
before_action -> { require_writable_tournament!(@stage.tournament) }, only: %i[update]
|
||||
|
||||
# GET /stages/1
|
||||
def show
|
||||
|
|
@ -23,6 +24,7 @@ class StagesController < ApplicationController
|
|||
handle_group_stage_end
|
||||
|
||||
render json: @stage
|
||||
push_sync_if_needed!(@stage.tournament)
|
||||
else
|
||||
render json: @stage.errors, status: :unprocessable_entity
|
||||
raise ActiveRecord::Rollback
|
||||
|
|
@ -75,4 +77,10 @@ class StagesController < ApplicationController
|
|||
def stage_params
|
||||
params.slice(:state).permit!
|
||||
end
|
||||
|
||||
def push_sync_if_needed!(tournament)
|
||||
TournamentSyncPusher.push!(tournament)
|
||||
rescue TournamentSyncPusher::SyncFailed => e
|
||||
logger.warn("Tournament sync push failed for #{tournament.id}: #{e.message}")
|
||||
end
|
||||
end
|
||||
|
|
|
|||
|
|
@ -4,6 +4,7 @@ class TeamsController < ApplicationController
|
|||
before_action :set_team, only: %i[show update]
|
||||
before_action :authenticate_user!, only: %i[update]
|
||||
before_action -> { require_owner! @team.owner }, only: %i[update]
|
||||
before_action -> { require_writable_tournament!(@team.tournament) }, only: %i[update]
|
||||
|
||||
# GET /teams/1
|
||||
def show
|
||||
|
|
@ -13,6 +14,7 @@ class TeamsController < ApplicationController
|
|||
# PATCH/PUT /teams/1
|
||||
def update
|
||||
if @team.update(team_params)
|
||||
push_sync_if_needed!(@team.tournament)
|
||||
render json: @team
|
||||
else
|
||||
render json: @team.errors, status: :unprocessable_entity
|
||||
|
|
@ -28,4 +30,10 @@ class TeamsController < ApplicationController
|
|||
def team_params
|
||||
params.slice(:name).permit!
|
||||
end
|
||||
|
||||
def push_sync_if_needed!(tournament)
|
||||
TournamentSyncPusher.push!(tournament)
|
||||
rescue TournamentSyncPusher::SyncFailed => e
|
||||
logger.warn("Tournament sync push failed for #{tournament.id}: #{e.message}")
|
||||
end
|
||||
end
|
||||
|
|
|
|||
|
|
@ -1,12 +1,16 @@
|
|||
# frozen_string_literal: true
|
||||
|
||||
class TournamentsController < ApplicationController
|
||||
before_action :set_tournament, only: %i[show update destroy set_timer_end timer_end]
|
||||
before_action :set_tournament, only: %i[show update destroy set_timer_end timer_end sync_state]
|
||||
before_action :authenticate_user!, only: %i[create update destroy set_timer_end]
|
||||
before_action -> { require_owner! @tournament.owner }, only: %i[update destroy set_timer_end]
|
||||
before_action :validate_create_params, only: %i[create]
|
||||
before_action :validate_update_params, only: %i[update]
|
||||
before_action :validate_set_timer_end_params, only: %i[set_timer_end]
|
||||
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_end]
|
||||
before_action :authorize_sync_request!, only: %i[sync_state]
|
||||
rescue_from ActiveRecord::RecordNotFound, with: :render_not_found_error
|
||||
|
||||
# GET /tournaments
|
||||
|
|
@ -37,11 +41,20 @@ class TournamentsController < ApplicationController
|
|||
# POST /tournaments
|
||||
def create
|
||||
params = tournament_params
|
||||
params.require(:teams)
|
||||
group_stage = params.delete(:group_stage)
|
||||
teams = params.delete('teams')
|
||||
# create tournament
|
||||
tournament = current_user.tournaments.new params
|
||||
tournament = current_user.tournaments.new params.except(:teams, :group_stage)
|
||||
|
||||
if tournament.read_only_mode?
|
||||
if tournament.save
|
||||
render json: tournament, status: :created, location: tournament
|
||||
else
|
||||
render json: tournament.errors, status: :unprocessable_entity
|
||||
end
|
||||
return
|
||||
end
|
||||
|
||||
raise ActionController::ParameterMissing, :teams if teams.nil?
|
||||
if group_stage
|
||||
params.require(:playoff_teams_amount)
|
||||
groups = organize_teams_in_groups(teams)
|
||||
|
|
@ -81,6 +94,7 @@ class TournamentsController < ApplicationController
|
|||
)
|
||||
end
|
||||
if @tournament.update(tournament_params)
|
||||
push_sync_if_needed!(@tournament)
|
||||
render json: @tournament
|
||||
else
|
||||
render json: @tournament.errors, status: :unprocessable_entity
|
||||
|
|
@ -102,12 +116,20 @@ class TournamentsController < ApplicationController
|
|||
# PATCH /tournaments/:id/set_timer_end
|
||||
def set_timer_end
|
||||
if @tournament.update(timer_end_params)
|
||||
push_sync_if_needed!(@tournament)
|
||||
render json: @tournament
|
||||
else
|
||||
render json: @tournament.errors, status: :unprocessable_entity
|
||||
end
|
||||
end
|
||||
|
||||
def sync_state
|
||||
TournamentSnapshotImporter.import!(tournament: @tournament, snapshot: sync_state_params)
|
||||
render json: @tournament, include: '**'
|
||||
rescue KeyError, ArgumentError => e
|
||||
render json: { error: e.message }, status: :unprocessable_entity
|
||||
end
|
||||
|
||||
|
||||
private
|
||||
|
||||
|
|
@ -146,10 +168,16 @@ class TournamentsController < ApplicationController
|
|||
end
|
||||
|
||||
def tournament_params
|
||||
params.slice(:name, :description, :public, :teams, :group_stage, :playoff_teams_amount).permit!
|
||||
params.slice(
|
||||
:name, :description, :public, :teams, :group_stage, :playoff_teams_amount,
|
||||
:instant_finalists_amount, :intermediate_round_participants_amount,
|
||||
:read_only_mode, :sync_target_url, :sync_auth_token
|
||||
).permit!
|
||||
end
|
||||
|
||||
def validate_create_params
|
||||
return if ActiveModel::Type::Boolean.new.cast(params[:read_only_mode])
|
||||
|
||||
teams = params['teams']
|
||||
return if teams.is_a?(Array) && teams.reject { |t| t.is_a? ActionController::Parameters }.count.zero?
|
||||
|
||||
|
|
@ -163,12 +191,21 @@ class TournamentsController < ApplicationController
|
|||
end
|
||||
|
||||
def validate_update_params
|
||||
return if sync_config_only_update?
|
||||
|
||||
return if only_playoff_teams_amount_changed
|
||||
|
||||
playoff_teams_amount = params['playoff_teams_amount'].to_i || @tournament.playoff_teams_amount
|
||||
instant_finalists_amount = params['instant_finalists_amount'].to_i || @tournament.instant_finalists_amount
|
||||
intermediate_round_participants_amount = params['intermediate_round_participants_amount'].to_i ||
|
||||
playoff_teams_amount = params.key?('playoff_teams_amount') ? params['playoff_teams_amount'].to_i : @tournament.playoff_teams_amount
|
||||
instant_finalists_amount = if params.key?('instant_finalists_amount')
|
||||
params['instant_finalists_amount'].to_i
|
||||
else
|
||||
@tournament.instant_finalists_amount
|
||||
end
|
||||
intermediate_round_participants_amount = if params.key?('intermediate_round_participants_amount')
|
||||
params['intermediate_round_participants_amount'].to_i
|
||||
else
|
||||
@tournament.intermediate_round_participants_amount
|
||||
end
|
||||
|
||||
return if instant_finalists_amount + (intermediate_round_participants_amount / 2) ==
|
||||
playoff_teams_amount
|
||||
|
|
@ -177,6 +214,49 @@ class TournamentsController < ApplicationController
|
|||
error: 'playoff_teams_amount, instant_finalists_amount and intermediate_round_participants_amount don\'t match'
|
||||
}, status: :unprocessable_entity
|
||||
end
|
||||
|
||||
def sync_state_params
|
||||
params.require(:snapshot).permit!.to_h
|
||||
end
|
||||
|
||||
def authorize_sync_request!
|
||||
return render json: { error: 'Tournament is not configured to accept sync pushes' }, status: :unprocessable_entity unless @tournament.sync_accepts_push?
|
||||
|
||||
token = request.authorization.to_s.delete_prefix('Bearer ').presence || request.headers['X-Tournament-Sync-Token'].to_s
|
||||
return render json: { error: 'Missing sync token' }, status: :unauthorized if token.blank?
|
||||
|
||||
matches = token.bytesize == @tournament.sync_auth_token.to_s.bytesize &&
|
||||
ActiveSupport::SecurityUtils.secure_compare(token, @tournament.sync_auth_token.to_s)
|
||||
return if matches
|
||||
|
||||
render json: { error: 'Invalid sync token' }, status: :unauthorized
|
||||
end
|
||||
|
||||
def ensure_update_allowed!
|
||||
return unless @tournament.read_only_mode?
|
||||
return if sync_config_only_update?
|
||||
|
||||
render_read_only_error
|
||||
end
|
||||
|
||||
def ensure_destroy_allowed!
|
||||
require_writable_tournament!(@tournament)
|
||||
end
|
||||
|
||||
def ensure_timer_update_allowed!
|
||||
require_writable_tournament!(@tournament)
|
||||
end
|
||||
|
||||
def sync_config_only_update?
|
||||
allowed = %w[id controller action read_only_mode sync_target_url sync_auth_token]
|
||||
(params.keys - allowed).empty?
|
||||
end
|
||||
|
||||
def push_sync_if_needed!(tournament)
|
||||
TournamentSyncPusher.push!(tournament)
|
||||
rescue TournamentSyncPusher::SyncFailed => e
|
||||
logger.warn("Tournament sync push failed for #{tournament.id}: #{e.message}")
|
||||
end
|
||||
end
|
||||
|
||||
def validate_set_timer_end_params
|
||||
|
|
|
|||
|
|
@ -6,6 +6,7 @@ class Group < ApplicationRecord
|
|||
has_many :group_scores, dependent: :destroy
|
||||
|
||||
delegate :owner, to: :stage
|
||||
delegate :tournament, to: :stage
|
||||
|
||||
def teams
|
||||
matches.map(&:teams).flatten.uniq
|
||||
|
|
|
|||
|
|
@ -22,6 +22,10 @@ class Match < ApplicationRecord
|
|||
stage ? stage.owner : group.owner
|
||||
end
|
||||
|
||||
def tournament
|
||||
stage ? stage.tournament : group.stage.tournament
|
||||
end
|
||||
|
||||
def current_leading_team
|
||||
return nil if match_scores.size < 2
|
||||
return nil if score_value_for(match_scores.first) == score_value_for(match_scores.second)
|
||||
|
|
|
|||
|
|
@ -5,6 +5,7 @@ class MatchScore < ApplicationRecord
|
|||
belongs_to :team
|
||||
|
||||
delegate :owner, to: :match
|
||||
delegate :tournament, to: :match
|
||||
|
||||
def part_of_group_match?
|
||||
match.group_match?
|
||||
|
|
|
|||
|
|
@ -9,6 +9,8 @@ class Tournament < ApplicationRecord
|
|||
|
||||
validates :name, presence: true
|
||||
validates :code, presence: true, uniqueness: true
|
||||
validates :sync_target_url, format: URI::DEFAULT_PARSER.make_regexp(%w[http https]), allow_blank: true
|
||||
validate :sync_configuration_is_complete
|
||||
|
||||
validate :playoff_teams_amount_is_positive_power_of_two
|
||||
|
||||
|
|
@ -32,6 +34,14 @@ class Tournament < ApplicationRecord
|
|||
stages.find_by(level: -1)
|
||||
end
|
||||
|
||||
def sync_push_enabled?
|
||||
sync_target_url.present? && sync_auth_token.present? && !read_only_mode?
|
||||
end
|
||||
|
||||
def sync_accepts_push?
|
||||
read_only_mode? && sync_auth_token.present?
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def generate_code
|
||||
|
|
@ -49,4 +59,12 @@ class Tournament < ApplicationRecord
|
|||
errors.add(:playoff_teams_amount,
|
||||
'playoff_teams_amount needs to be a positive power of two')
|
||||
end
|
||||
|
||||
def sync_configuration_is_complete
|
||||
return if sync_target_url.blank? && sync_auth_token.blank?
|
||||
return if sync_target_url.present? && sync_auth_token.present?
|
||||
return if read_only_mode? && sync_auth_token.present? && sync_target_url.blank?
|
||||
|
||||
errors.add(:base, 'sync_target_url and sync_auth_token must be configured together')
|
||||
end
|
||||
end
|
||||
|
|
|
|||
|
|
@ -0,0 +1,111 @@
|
|||
# frozen_string_literal: true
|
||||
|
||||
class TournamentSnapshotBuilder
|
||||
def self.build(tournament)
|
||||
new(tournament).build
|
||||
end
|
||||
|
||||
def initialize(tournament)
|
||||
@tournament = tournament
|
||||
end
|
||||
|
||||
def build
|
||||
{
|
||||
id: tournament.id,
|
||||
name: tournament.name,
|
||||
code: tournament.code,
|
||||
description: tournament.description,
|
||||
public: tournament.public,
|
||||
playoff_teams_amount: tournament.playoff_teams_amount,
|
||||
instant_finalists_amount: tournament.instant_finalists_amount,
|
||||
intermediate_round_participants_amount: tournament.intermediate_round_participants_amount,
|
||||
timer_end: tournament.timer_end&.iso8601,
|
||||
teams: teams_payload,
|
||||
stages: stages_payload,
|
||||
groups: groups_payload,
|
||||
matches: matches_payload,
|
||||
match_scores: match_scores_payload,
|
||||
group_scores: group_scores_payload
|
||||
}
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
attr_reader :tournament
|
||||
|
||||
def teams_payload
|
||||
teams_for_snapshot.sort_by(&:id).map do |team|
|
||||
{
|
||||
id: team.id,
|
||||
name: team.name
|
||||
}
|
||||
end
|
||||
end
|
||||
|
||||
def stages_payload
|
||||
tournament.stages.order(:id).map do |stage|
|
||||
{
|
||||
id: stage.id,
|
||||
level: stage.level,
|
||||
state: stage.state
|
||||
}
|
||||
end
|
||||
end
|
||||
|
||||
def groups_payload
|
||||
tournament.stages.flat_map(&:groups).sort_by(&:id).map do |group|
|
||||
{
|
||||
id: group.id,
|
||||
stage_id: group.stage_id,
|
||||
number: group.number
|
||||
}
|
||||
end
|
||||
end
|
||||
|
||||
def matches_payload
|
||||
tournament.matches.sort_by(&:id).map do |match|
|
||||
{
|
||||
id: match.id,
|
||||
stage_id: match.stage&.id,
|
||||
group_id: match.group&.id,
|
||||
state: match.state,
|
||||
position: match.position,
|
||||
decider: match.decider
|
||||
}
|
||||
end
|
||||
end
|
||||
|
||||
def match_scores_payload
|
||||
tournament.matches.flat_map(&:match_scores).sort_by(&:id).map do |match_score|
|
||||
{
|
||||
id: match_score.id,
|
||||
match_id: match_score.match_id,
|
||||
team_id: match_score.team_id,
|
||||
points: match_score.points,
|
||||
hidden_points: match_score.hidden_points
|
||||
}
|
||||
end
|
||||
end
|
||||
|
||||
def group_scores_payload
|
||||
tournament.stages.flat_map(&:groups).flat_map(&:group_scores).sort_by(&:id).map do |group_score|
|
||||
{
|
||||
id: group_score.id,
|
||||
group_id: group_score.group_id,
|
||||
team_id: group_score.team_id,
|
||||
group_points: group_score.group_points,
|
||||
scored_points: group_score.scored_points,
|
||||
received_points: group_score.received_points,
|
||||
position: group_score.position
|
||||
}
|
||||
end
|
||||
end
|
||||
|
||||
def teams_for_snapshot
|
||||
(
|
||||
tournament.teams.to_a +
|
||||
tournament.matches.flat_map(&:teams) +
|
||||
tournament.stages.flat_map(&:groups).flat_map(&:group_scores).map(&:team)
|
||||
).uniq(&:id)
|
||||
end
|
||||
end
|
||||
|
|
@ -0,0 +1,170 @@
|
|||
# frozen_string_literal: true
|
||||
|
||||
class TournamentSnapshotImporter
|
||||
ROOT_ATTRIBUTES = %i[
|
||||
name
|
||||
code
|
||||
description
|
||||
public
|
||||
playoff_teams_amount
|
||||
instant_finalists_amount
|
||||
intermediate_round_participants_amount
|
||||
].freeze
|
||||
|
||||
def self.import!(tournament:, snapshot:)
|
||||
new(tournament: tournament, snapshot: snapshot).import!
|
||||
end
|
||||
|
||||
def initialize(tournament:, snapshot:)
|
||||
@tournament = tournament
|
||||
@snapshot = snapshot.deep_symbolize_keys
|
||||
end
|
||||
|
||||
def import!
|
||||
Tournament.transaction do
|
||||
tournament.lock!
|
||||
|
||||
existing_teams = tournament.teams.to_a
|
||||
existing_stages = tournament.stages.to_a
|
||||
existing_groups = existing_stages.flat_map(&:groups)
|
||||
existing_matches = tournament.matches
|
||||
existing_match_scores = existing_matches.flat_map(&:match_scores)
|
||||
existing_group_scores = existing_groups.flat_map(&:group_scores)
|
||||
|
||||
update_tournament!
|
||||
teams_by_source_id = sync_teams!
|
||||
stages_by_source_id = sync_stages!
|
||||
groups_by_source_id = sync_groups!(stages_by_source_id)
|
||||
matches_by_source_id = sync_matches!(stages_by_source_id, groups_by_source_id)
|
||||
sync_match_scores!(matches_by_source_id, teams_by_source_id)
|
||||
sync_group_scores!(groups_by_source_id, teams_by_source_id)
|
||||
|
||||
destroy_stale_records(existing_group_scores, snapshot.fetch(:group_scores, []))
|
||||
destroy_stale_records(existing_match_scores, snapshot.fetch(:match_scores, []))
|
||||
destroy_stale_records(existing_matches, snapshot.fetch(:matches, []))
|
||||
destroy_stale_records(existing_groups, snapshot.fetch(:groups, []))
|
||||
destroy_stale_records(existing_stages, snapshot.fetch(:stages, []))
|
||||
destroy_stale_records(existing_teams, snapshot.fetch(:teams, []))
|
||||
end
|
||||
|
||||
tournament
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
attr_reader :tournament, :snapshot
|
||||
|
||||
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[:timer_end] = parse_time(snapshot[:timer_end])
|
||||
root_attrs[:sync_source_tournament_id] = snapshot[:id]
|
||||
tournament.update!(root_attrs)
|
||||
end
|
||||
|
||||
def sync_teams!
|
||||
sync_records(
|
||||
model_class: Team,
|
||||
existing_records: tournament.teams.to_a,
|
||||
payload: snapshot.fetch(:teams, [])
|
||||
) do |team, attrs|
|
||||
team.tournament = tournament
|
||||
team.name = attrs.fetch(:name)
|
||||
end
|
||||
end
|
||||
|
||||
def sync_stages!
|
||||
sync_records(
|
||||
model_class: Stage,
|
||||
existing_records: tournament.stages.to_a,
|
||||
payload: snapshot.fetch(:stages, [])
|
||||
) do |stage, attrs|
|
||||
stage.tournament = tournament
|
||||
stage.level = attrs.fetch(:level)
|
||||
stage.state = attrs.fetch(:state)
|
||||
end
|
||||
end
|
||||
|
||||
def sync_groups!(stages_by_source_id)
|
||||
sync_records(
|
||||
model_class: Group,
|
||||
existing_records: tournament.stages.flat_map(&:groups),
|
||||
payload: snapshot.fetch(:groups, [])
|
||||
) do |group, attrs|
|
||||
group.stage = stages_by_source_id.fetch(attrs.fetch(:stage_id))
|
||||
group.number = attrs.fetch(:number)
|
||||
end
|
||||
end
|
||||
|
||||
def sync_matches!(stages_by_source_id, groups_by_source_id)
|
||||
sync_records(
|
||||
model_class: Match,
|
||||
existing_records: tournament.matches,
|
||||
payload: snapshot.fetch(:matches, [])
|
||||
) do |match, attrs|
|
||||
match.stage = attrs[:stage_id] ? stages_by_source_id.fetch(attrs[:stage_id]) : nil
|
||||
match.group = attrs[:group_id] ? groups_by_source_id.fetch(attrs[:group_id]) : nil
|
||||
match.state = attrs.fetch(:state)
|
||||
match.position = attrs[:position]
|
||||
match.decider = attrs[:decider]
|
||||
end
|
||||
end
|
||||
|
||||
def sync_match_scores!(matches_by_source_id, teams_by_source_id)
|
||||
sync_records(
|
||||
model_class: MatchScore,
|
||||
existing_records: tournament.matches.flat_map(&:match_scores),
|
||||
payload: snapshot.fetch(:match_scores, [])
|
||||
) do |match_score, attrs|
|
||||
match_score.match = matches_by_source_id.fetch(attrs.fetch(:match_id))
|
||||
match_score.team = teams_by_source_id.fetch(attrs.fetch(:team_id))
|
||||
match_score.points = attrs.fetch(:points)
|
||||
match_score.hidden_points = attrs.fetch(:hidden_points, 0)
|
||||
end
|
||||
end
|
||||
|
||||
def sync_group_scores!(groups_by_source_id, teams_by_source_id)
|
||||
sync_records(
|
||||
model_class: GroupScore,
|
||||
existing_records: tournament.stages.flat_map(&:groups).flat_map(&:group_scores),
|
||||
payload: snapshot.fetch(:group_scores, [])
|
||||
) do |group_score, attrs|
|
||||
group_score.group = groups_by_source_id.fetch(attrs.fetch(:group_id))
|
||||
group_score.team = teams_by_source_id.fetch(attrs.fetch(:team_id))
|
||||
group_score.group_points = attrs.fetch(:group_points)
|
||||
group_score.scored_points = attrs.fetch(:scored_points)
|
||||
group_score.received_points = attrs.fetch(:received_points)
|
||||
group_score.position = attrs.fetch(:position, 0)
|
||||
end
|
||||
end
|
||||
|
||||
def sync_records(model_class:, existing_records:, payload:)
|
||||
existing_by_source_id = existing_records.index_by(&:sync_source_id)
|
||||
|
||||
payload.each_with_object({}) do |attrs, records_by_source_id|
|
||||
source_id = attrs.fetch(:id)
|
||||
record = existing_by_source_id[source_id] || model_class.new
|
||||
record.sync_source_id = source_id
|
||||
yield(record, attrs)
|
||||
record.save!
|
||||
records_by_source_id[source_id] = record
|
||||
end
|
||||
end
|
||||
|
||||
def destroy_stale_records(existing_records, payload)
|
||||
source_ids = payload.map { |attrs| attrs.fetch(:id) }
|
||||
stale_records = existing_records.reject { |record| source_ids.include?(record.sync_source_id) }
|
||||
stale_records.each(&:destroy!)
|
||||
end
|
||||
def parse_time(value)
|
||||
return nil if value.blank?
|
||||
|
||||
Time.zone.parse(value)
|
||||
end
|
||||
|
||||
def code_taken_by_another_tournament?(code)
|
||||
return false if code.blank?
|
||||
|
||||
Tournament.where(code: code).where.not(id: tournament.id).exists?
|
||||
end
|
||||
end
|
||||
|
|
@ -0,0 +1,53 @@
|
|||
# frozen_string_literal: true
|
||||
|
||||
require 'json'
|
||||
require 'net/http'
|
||||
require 'uri'
|
||||
|
||||
class TournamentSyncPusher
|
||||
class SyncFailed < StandardError; end
|
||||
|
||||
def self.push!(tournament)
|
||||
new(tournament).push!
|
||||
end
|
||||
|
||||
def initialize(tournament)
|
||||
@tournament = tournament
|
||||
end
|
||||
|
||||
def push!
|
||||
return false unless tournament.sync_push_enabled?
|
||||
|
||||
response = perform_request(snapshot: TournamentSnapshotBuilder.build(tournament))
|
||||
|
||||
unless response.is_a?(Net::HTTPSuccess)
|
||||
raise SyncFailed, "sync push failed with status #{response.code}: #{response.body}"
|
||||
end
|
||||
|
||||
tournament.update_columns(sync_last_pushed_at: Time.current, sync_last_push_error: nil)
|
||||
true
|
||||
rescue SyncFailed => e
|
||||
tournament.update_columns(sync_last_push_error: e.message)
|
||||
raise
|
||||
rescue StandardError => e
|
||||
tournament.update_columns(sync_last_push_error: e.message)
|
||||
raise SyncFailed, e.message
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
attr_reader :tournament
|
||||
|
||||
def perform_request(snapshot:)
|
||||
uri = URI.parse(tournament.sync_target_url)
|
||||
request = Net::HTTP::Patch.new(uri)
|
||||
request['accept'] = 'application/json'
|
||||
request['content-type'] = 'application/json'
|
||||
request['authorization'] = "Bearer #{tournament.sync_auth_token}"
|
||||
request.body = JSON.dump({ snapshot: snapshot })
|
||||
|
||||
Net::HTTP.start(uri.host, uri.port, use_ssl: uri.scheme == 'https') do |http|
|
||||
http.request(request)
|
||||
end
|
||||
end
|
||||
end
|
||||
|
|
@ -20,6 +20,7 @@ Rails.application.routes.draw do
|
|||
member do
|
||||
get :timer_end
|
||||
patch :set_timer_end
|
||||
patch :sync_state
|
||||
end
|
||||
end
|
||||
resources :match_scores, only: %i[show update]
|
||||
|
|
|
|||
|
|
@ -0,0 +1,38 @@
|
|||
# frozen_string_literal: true
|
||||
|
||||
class AddTournamentSyncSupport < ActiveRecord::Migration[7.0]
|
||||
def change
|
||||
change_table :tournaments, bulk: true do |t|
|
||||
t.boolean :read_only_mode, null: false, default: false
|
||||
t.string :sync_target_url
|
||||
t.string :sync_auth_token
|
||||
t.integer :sync_source_tournament_id
|
||||
t.datetime :sync_last_pushed_at
|
||||
t.string :sync_last_push_error
|
||||
end
|
||||
|
||||
change_table :teams, bulk: true do |t|
|
||||
t.integer :sync_source_id
|
||||
end
|
||||
|
||||
change_table :stages, bulk: true do |t|
|
||||
t.integer :sync_source_id
|
||||
end
|
||||
|
||||
change_table :groups, bulk: true do |t|
|
||||
t.integer :sync_source_id
|
||||
end
|
||||
|
||||
change_table :matches, bulk: true do |t|
|
||||
t.integer :sync_source_id
|
||||
end
|
||||
|
||||
change_table :match_scores, bulk: true do |t|
|
||||
t.integer :sync_source_id
|
||||
end
|
||||
|
||||
change_table :group_scores, bulk: true do |t|
|
||||
t.integer :sync_source_id
|
||||
end
|
||||
end
|
||||
end
|
||||
|
|
@ -176,6 +176,25 @@ RSpec.describe TournamentsController, type: :controller do
|
|||
apply_authentication_headers_for @user
|
||||
end
|
||||
|
||||
context 'with read_only_mode=true' do
|
||||
it 'creates an empty follower tournament without teams payload' do
|
||||
expect do
|
||||
post :create, params: {
|
||||
name: 'Follower Tournament',
|
||||
public: false,
|
||||
read_only_mode: true,
|
||||
sync_auth_token: 'shared-secret'
|
||||
}
|
||||
end.to change(Tournament, :count).by(1)
|
||||
|
||||
expect(response).to have_http_status(:created)
|
||||
tournament = Tournament.find(deserialize_response(response)[:id])
|
||||
expect(tournament.read_only_mode?).to eq(true)
|
||||
expect(tournament.teams).to be_empty
|
||||
expect(tournament.stages).to be_empty
|
||||
end
|
||||
end
|
||||
|
||||
context 'with existing teams' do
|
||||
it 'creates a new Tournament' do
|
||||
expect do
|
||||
|
|
@ -361,6 +380,32 @@ RSpec.describe TournamentsController, type: :controller do
|
|||
expect(response.media_type).to eq('application/json')
|
||||
end
|
||||
|
||||
it 'allows sync config changes on read only tournaments' do
|
||||
@tournament.update!(read_only_mode: true, sync_auth_token: 'old-token')
|
||||
|
||||
put :update, params: {
|
||||
id: @tournament.to_param,
|
||||
read_only_mode: false,
|
||||
sync_target_url: 'https://remote.example.com/tournaments/1/sync_state',
|
||||
sync_auth_token: 'new-token'
|
||||
}
|
||||
|
||||
expect(response).to have_http_status(:ok)
|
||||
@tournament.reload
|
||||
expect(@tournament.read_only_mode?).to eq(false)
|
||||
expect(@tournament.sync_target_url).to include('/sync_state')
|
||||
expect(@tournament.sync_auth_token).to eq('new-token')
|
||||
end
|
||||
|
||||
it 'blocks normal updates while tournament is read only' do
|
||||
@tournament.update!(read_only_mode: true, sync_auth_token: 'shared-secret')
|
||||
|
||||
put :update, params: { id: @tournament.to_param, name: 'Should Not Work' }
|
||||
|
||||
expect(response).to have_http_status(:locked)
|
||||
expect(deserialize_response(response)[:error]).to eq('Tournament is in read only follower mode')
|
||||
end
|
||||
|
||||
context 'any variable relevant for group stage to playoff transition changed' do
|
||||
before(:each) do
|
||||
@filled_tournament = create(:group_stage_tournament)
|
||||
|
|
@ -494,6 +539,14 @@ RSpec.describe TournamentsController, type: :controller do
|
|||
delete :destroy, params: { id: @tournament.to_param }
|
||||
end.to change(Team, :count).by(-@tournament.teams.size)
|
||||
end
|
||||
|
||||
it 'blocks destroy while tournament is read only' do
|
||||
@tournament.update!(read_only_mode: true, sync_auth_token: 'shared-secret')
|
||||
|
||||
delete :destroy, params: { id: @tournament.to_param }
|
||||
|
||||
expect(response).to have_http_status(:locked)
|
||||
end
|
||||
end
|
||||
|
||||
context 'as another user' do
|
||||
|
|
@ -507,4 +560,53 @@ RSpec.describe TournamentsController, type: :controller do
|
|||
end
|
||||
end
|
||||
end
|
||||
|
||||
describe 'PATCH #sync_state' do
|
||||
before do
|
||||
@tournament.update!(read_only_mode: true, sync_auth_token: 'shared-secret')
|
||||
end
|
||||
|
||||
it 'imports a pushed snapshot' do
|
||||
request.headers['Authorization'] = 'Bearer shared-secret'
|
||||
|
||||
patch :sync_state, params: {
|
||||
id: @tournament.to_param,
|
||||
snapshot: {
|
||||
id: 123,
|
||||
name: 'Synced Tournament',
|
||||
code: 'abc123',
|
||||
description: 'Synced description',
|
||||
public: true,
|
||||
playoff_teams_amount: 4,
|
||||
instant_finalists_amount: 4,
|
||||
intermediate_round_participants_amount: 0,
|
||||
timer_end: nil,
|
||||
teams: [{ id: 7, name: 'Alpha' }],
|
||||
stages: [],
|
||||
groups: [],
|
||||
matches: [],
|
||||
match_scores: [],
|
||||
group_scores: []
|
||||
}
|
||||
}
|
||||
|
||||
expect(response).to have_http_status(:ok)
|
||||
@tournament.reload
|
||||
expect(@tournament.sync_source_tournament_id).to eq(123)
|
||||
expect(@tournament.name).to eq('Synced Tournament')
|
||||
expect(@tournament.teams.pluck(:name)).to eq(['Alpha'])
|
||||
end
|
||||
|
||||
it 'rejects invalid tokens' do
|
||||
request.headers['Authorization'] = 'Bearer wrong-token'
|
||||
|
||||
patch :sync_state, params: {
|
||||
id: @tournament.to_param,
|
||||
snapshot: { id: 123 }
|
||||
}
|
||||
|
||||
expect(response).to have_http_status(:unauthorized)
|
||||
expect(deserialize_response(response)[:error]).to eq('Invalid sync token')
|
||||
end
|
||||
end
|
||||
end
|
||||
|
|
|
|||
|
|
@ -0,0 +1,285 @@
|
|||
# frozen_string_literal: true
|
||||
|
||||
require 'spec_helper'
|
||||
require 'securerandom'
|
||||
require_relative '../../../e2e/lib/api_client'
|
||||
|
||||
RSpec.describe 'Tournament follower sync HTTP E2E' do
|
||||
required_env_vars = %w[
|
||||
TURNIERE_E2E_EMAIL
|
||||
TURNIERE_E2E_PASSWORD
|
||||
TURNIERE_SOURCE_E2E_BASE_URL
|
||||
TURNIERE_FOLLOWER_E2E_BASE_URL
|
||||
].freeze
|
||||
|
||||
before do
|
||||
missing_vars = required_env_vars.reject { |name| ENV.key?(name) }
|
||||
skip("HTTP E2E requires #{missing_vars.join(', ')}") unless missing_vars.empty?
|
||||
end
|
||||
|
||||
let(:source_base_url) { ENV.fetch('TURNIERE_SOURCE_E2E_BASE_URL') }
|
||||
let(:follower_base_url) { ENV.fetch('TURNIERE_FOLLOWER_E2E_BASE_URL') }
|
||||
let(:owner_email) { ENV.fetch('TURNIERE_E2E_EMAIL') }
|
||||
let(:owner_password) { ENV.fetch('TURNIERE_E2E_PASSWORD') }
|
||||
let(:sync_token) { "sync-#{SecureRandom.hex(8)}" }
|
||||
let(:source_owner_client) { login_client(base_url: source_base_url, email: owner_email, password: owner_password) }
|
||||
let(:follower_owner_client) { login_client(base_url: follower_base_url, email: owner_email, password: owner_password) }
|
||||
let(:source_anonymous_client) { TurniereE2E::ApiClient.new(base_url: source_base_url) }
|
||||
let(:follower_anonymous_client) { TurniereE2E::ApiClient.new(base_url: follower_base_url) }
|
||||
|
||||
it 'pushes full source lifecycle into read only follower tournament' do
|
||||
source = create_group_stage_tournament(client: source_owner_client, public: false, name_prefix: 'Sync Source')
|
||||
follower = create_follower_tournament(client: follower_owner_client, public: true, name_prefix: 'Sync Follower')
|
||||
|
||||
configure_sync = source_owner_client.patch("/tournaments/#{source.fetch(:id)}", body: {
|
||||
sync_target_url: "#{follower_base_url}/tournaments/#{follower.fetch(:id)}/sync_state",
|
||||
sync_auth_token: sync_token
|
||||
})
|
||||
expect(configure_sync[:status]).to eq(200)
|
||||
|
||||
source = fetch_tournament(client: source_anonymous_client, tournament_id: source.fetch(:id))
|
||||
follower = fetch_tournament(client: follower_anonymous_client, tournament_id: follower.fetch(:id))
|
||||
expect(tournament_signature(follower)).to eq(tournament_signature(source))
|
||||
|
||||
update_cutoff = source_owner_client.patch("/tournaments/#{source.fetch(:id)}", body: {
|
||||
playoff_teams_amount: 4,
|
||||
instant_finalists_amount: 4,
|
||||
intermediate_round_participants_amount: 0
|
||||
})
|
||||
expect(update_cutoff[:status]).to eq(200)
|
||||
|
||||
source_group_stage = fetch_tournament(client: source_anonymous_client, tournament_id: source.fetch(:id))
|
||||
.fetch(:stages).find { |stage| stage.fetch(:level) == -1 }
|
||||
play_group_with_decider_lifecycle!(source_id: source.fetch(:id), groups: source_group_stage.fetch(:groups).sort_by { |group| group.fetch(:number) })
|
||||
|
||||
source = fetch_tournament(client: source_anonymous_client, tournament_id: source.fetch(:id))
|
||||
follower = fetch_tournament(client: follower_anonymous_client, tournament_id: follower.fetch(:id))
|
||||
expect(tournament_signature(follower)).to eq(tournament_signature(source))
|
||||
expect(follower.fetch(:stages).map { |stage| stage.fetch(:level) }).to include(-1, 0, 1)
|
||||
|
||||
follower_group = follower.fetch(:stages).find { |stage| stage.fetch(:level) == -1 }.fetch(:groups).first
|
||||
follower_team = follower_group.fetch(:matches).first.fetch(:match_scores).first.fetch(:team)
|
||||
locked_team_update = follower_owner_client.patch("/teams/#{follower_team.fetch(:id)}", body: { name: 'Nope' })
|
||||
expect(locked_team_update[:status]).to eq(423)
|
||||
expect(locked_team_update.dig(:json, :error)).to eq('Tournament is in read only follower mode')
|
||||
|
||||
finish_playoff_bracket!(source_id: source.fetch(:id))
|
||||
|
||||
source = fetch_tournament(client: source_anonymous_client, tournament_id: source.fetch(:id))
|
||||
follower = fetch_tournament(client: follower_anonymous_client, tournament_id: follower.fetch(:id))
|
||||
expect(tournament_signature(follower)).to eq(tournament_signature(source))
|
||||
|
||||
disable_follower = follower_owner_client.patch("/tournaments/#{follower.fetch(:id)}", body: { read_only_mode: false })
|
||||
expect(disable_follower[:status]).to eq(200)
|
||||
|
||||
follower_after_disable = fetch_tournament(client: follower_anonymous_client, tournament_id: follower.fetch(:id))
|
||||
writable_team = follower_after_disable.fetch(:teams).first
|
||||
takeover_update = follower_owner_client.patch("/teams/#{writable_team.fetch(:id)}", body: { name: "#{writable_team.fetch(:name)} takeover" })
|
||||
expect(takeover_update[:status]).to eq(200)
|
||||
end
|
||||
|
||||
def login_client(base_url:, email:, password:)
|
||||
client = TurniereE2E::ApiClient.new(base_url: base_url)
|
||||
response = client.login!(email: email, password: password)
|
||||
|
||||
expect(response[:status]).to eq(200)
|
||||
expect(client.authenticated?).to eq(true)
|
||||
client
|
||||
end
|
||||
|
||||
def create_group_stage_tournament(client:, public:, name_prefix:)
|
||||
payload = {
|
||||
name: "#{name_prefix} #{unique_suffix}",
|
||||
description: 'Follower sync source tournament',
|
||||
public: public,
|
||||
group_stage: true,
|
||||
playoff_teams_amount: 8,
|
||||
teams: 4.times.flat_map do |group_index|
|
||||
4.times.map do |seed_index|
|
||||
{
|
||||
name: "#{name_prefix.tr(' ', '')}-G#{group_index + 1}-S#{seed_index + 1}",
|
||||
group: group_index
|
||||
}
|
||||
end
|
||||
end
|
||||
}
|
||||
|
||||
response = client.post('/tournaments', body: payload)
|
||||
expect(response[:status]).to eq(201)
|
||||
fetch_tournament(client: client, tournament_id: response.dig(:json, :id))
|
||||
end
|
||||
|
||||
def create_follower_tournament(client:, public:, name_prefix:)
|
||||
response = client.post('/tournaments', body: {
|
||||
name: "#{name_prefix} #{unique_suffix}",
|
||||
description: 'Follower tournament',
|
||||
public: public,
|
||||
read_only_mode: true,
|
||||
sync_auth_token: sync_token
|
||||
})
|
||||
expect(response[:status]).to eq(201)
|
||||
fetch_tournament(client: client, tournament_id: response.dig(:json, :id))
|
||||
end
|
||||
|
||||
def fetch_tournament(client:, tournament_id:)
|
||||
response = client.get("/tournaments/#{tournament_id}")
|
||||
expect(response[:status]).to eq(200)
|
||||
response.fetch(:json)
|
||||
end
|
||||
|
||||
def play_group_with_decider_lifecycle!(source_id:, groups:)
|
||||
groups.each_with_index do |group, index|
|
||||
if index.zero?
|
||||
play_group_matches_for_decider!(group)
|
||||
else
|
||||
finish_group_clear_winner!(group)
|
||||
end
|
||||
end
|
||||
|
||||
group_stage = fetch_tournament(client: source_anonymous_client, tournament_id: source_id)
|
||||
.fetch(:stages).find { |stage| stage.fetch(:level) == -1 }
|
||||
|
||||
stage_finish = source_owner_client.patch("/stages/#{group_stage.fetch(:id)}", body: { state: 'finished' })
|
||||
expect(stage_finish[:status]).to eq(422)
|
||||
expect(stage_finish.dig(:json, :error)).to eq('Playoffs cannot be generated yet because a decider match is required for an advancing place')
|
||||
|
||||
blocking_tie = stage_finish.dig(:json, :blocking_ties).first
|
||||
decider_create = source_owner_client.post("/groups/#{blocking_tie.fetch(:group_id)}/matches", body: {
|
||||
team_ids: blocking_tie.fetch(:team_ids)
|
||||
})
|
||||
expect(decider_create[:status]).to eq(201)
|
||||
decider_match = decider_create.fetch(:json)
|
||||
|
||||
start_decider = source_owner_client.patch("/matches/#{decider_match.fetch(:id)}", body: { state: 'in_progress' })
|
||||
expect(start_decider[:status]).to eq(200)
|
||||
decider_match.fetch(:match_scores).sort_by { |score| score.fetch(:id) }.each_with_index do |score, index|
|
||||
response = source_owner_client.patch("/match_scores/#{score.fetch(:id)}", body: { hidden_points: index.zero? ? 5 : 3 })
|
||||
expect(response[:status]).to eq(200)
|
||||
end
|
||||
finish_decider = source_owner_client.patch("/matches/#{decider_match.fetch(:id)}", body: { state: 'finished' })
|
||||
expect(finish_decider[:status]).to eq(200)
|
||||
|
||||
finish_stage_after_decider = source_owner_client.patch("/stages/#{group_stage.fetch(:id)}", body: { state: 'finished' })
|
||||
expect(finish_stage_after_decider[:status]).to eq(200)
|
||||
end
|
||||
|
||||
def play_group_matches_for_decider!(group)
|
||||
teams = group_team_map(group)
|
||||
play_group_match!(group, teams.fetch(1), teams.fetch(2), 1, 1)
|
||||
play_group_match!(group, teams.fetch(1), teams.fetch(3), 2, 0)
|
||||
play_group_match!(group, teams.fetch(1), teams.fetch(4), 2, 0)
|
||||
play_group_match!(group, teams.fetch(2), teams.fetch(3), 2, 0)
|
||||
play_group_match!(group, teams.fetch(2), teams.fetch(4), 2, 0)
|
||||
play_group_match!(group, teams.fetch(3), teams.fetch(4), 1, 0)
|
||||
end
|
||||
|
||||
def finish_group_clear_winner!(group)
|
||||
group.fetch(:matches).each_with_index do |match, index|
|
||||
play_match!(match_id: match.fetch(:id), first_score: 10 + index, second_score: index)
|
||||
end
|
||||
end
|
||||
|
||||
def group_team_map(group)
|
||||
group.fetch(:matches)
|
||||
.flat_map { |match| match.fetch(:match_scores).map { |score| score.fetch(:team) } }
|
||||
.uniq { |team| team.fetch(:id) }
|
||||
.sort_by { |team| team.fetch(:name) }
|
||||
.each_with_index
|
||||
.to_h { |(team, index)| [index + 1, team] }
|
||||
end
|
||||
|
||||
def play_group_match!(group, team_a, team_b, score_a, score_b)
|
||||
match = group.fetch(:matches).find do |candidate|
|
||||
team_ids = candidate.fetch(:match_scores).map { |score| score.dig(:team, :id) }.sort
|
||||
team_ids == [team_a.fetch(:id), team_b.fetch(:id)].sort
|
||||
end
|
||||
|
||||
play_match!(match_id: match.fetch(:id), first_score: score_a, second_score: score_b)
|
||||
end
|
||||
|
||||
def finish_playoff_bracket!(source_id:)
|
||||
loop do
|
||||
tournament = fetch_tournament(client: source_anonymous_client, tournament_id: source_id)
|
||||
playoff_matches = tournament.fetch(:stages)
|
||||
.select { |stage| stage.fetch(:level) >= 0 }
|
||||
.flat_map { |stage| stage.fetch(:matches) }
|
||||
.select { |match| %w[not_started in_progress].include?(match.fetch(:state)) && match.fetch(:match_scores).size == 2 }
|
||||
.sort_by { |match| [match.dig(:stage, :id) || 0, match.fetch(:position)] }
|
||||
break if playoff_matches.empty?
|
||||
|
||||
playoff_matches.each_with_index do |match, index|
|
||||
play_match!(match_id: match.fetch(:id), first_score: 20 + index, second_score: index)
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
def play_match!(match_id:, first_score:, second_score:)
|
||||
match = source_anonymous_client.get("/matches/#{match_id}")
|
||||
expect(match[:status]).to eq(200)
|
||||
|
||||
unless match.dig(:json, :state) == 'in_progress'
|
||||
start_response = source_owner_client.patch("/matches/#{match_id}", body: { state: 'in_progress' })
|
||||
expect(start_response[:status]).to eq(200)
|
||||
match = source_anonymous_client.get("/matches/#{match_id}")
|
||||
end
|
||||
|
||||
ordered_scores = match.fetch(:json).fetch(:match_scores).sort_by { |score| score.fetch(:id) }
|
||||
[first_score, second_score].each_with_index do |points, index|
|
||||
response = source_owner_client.patch("/match_scores/#{ordered_scores[index].fetch(:id)}", body: { points: points })
|
||||
expect(response[:status]).to eq(200)
|
||||
end
|
||||
|
||||
finish_response = source_owner_client.patch("/matches/#{match_id}", body: { state: 'finished' })
|
||||
expect(finish_response[:status]).to eq(200)
|
||||
end
|
||||
|
||||
def tournament_signature(tournament)
|
||||
{
|
||||
name: tournament.fetch(:name),
|
||||
public: tournament.fetch(:public),
|
||||
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),
|
||||
timer_end: tournament[:timer_end],
|
||||
teams: tournament.fetch(:teams).map { |team| team.slice(:name, :advancing_from_group_stage) }.sort_by { |team| team.fetch(:name) },
|
||||
stages: tournament.fetch(:stages).sort_by { |stage| stage.fetch(:level) }.map do |stage|
|
||||
{
|
||||
level: stage.fetch(:level),
|
||||
state: stage.fetch(:state),
|
||||
groups: stage.fetch(:groups).sort_by { |group| group.fetch(:number) }.map do |group|
|
||||
{
|
||||
number: group.fetch(:number),
|
||||
matches: group.fetch(:matches).sort_by { |match| match.fetch(:position) }.map do |match|
|
||||
match_signature(match)
|
||||
end
|
||||
}
|
||||
end,
|
||||
matches: stage.fetch(:matches).sort_by { |match| match.fetch(:position) }.map do |match|
|
||||
match_signature(match)
|
||||
end
|
||||
}
|
||||
end
|
||||
}
|
||||
end
|
||||
|
||||
def match_signature(match)
|
||||
{
|
||||
state: match.fetch(:state),
|
||||
position: match.fetch(:position),
|
||||
decider: match.fetch(:decider),
|
||||
scores: match.fetch(:match_scores).map do |score|
|
||||
{
|
||||
team_name: score.dig(:team, :name),
|
||||
points: score.fetch(:points),
|
||||
hidden_points: score.fetch(:hidden_points)
|
||||
}
|
||||
end.sort_by { |score| score.fetch(:team_name).to_s }
|
||||
}
|
||||
end
|
||||
|
||||
def unique_suffix
|
||||
@unique_suffix_counter ||= 0
|
||||
@unique_suffix_counter += 1
|
||||
"#{SecureRandom.hex(3)}-#{@unique_suffix_counter}"
|
||||
end
|
||||
end
|
||||
|
|
@ -27,5 +27,9 @@ RSpec.describe TournamentsController, type: :routing do
|
|||
it 'routes to #destroy' do
|
||||
expect(delete: '/tournaments/1').to route_to('tournaments#destroy', id: '1')
|
||||
end
|
||||
|
||||
it 'routes to #sync_state' do
|
||||
expect(patch: '/tournaments/1/sync_state').to route_to('tournaments#sync_state', id: '1')
|
||||
end
|
||||
end
|
||||
end
|
||||
|
|
|
|||
|
|
@ -0,0 +1,24 @@
|
|||
# frozen_string_literal: true
|
||||
|
||||
require 'rails_helper'
|
||||
|
||||
RSpec.describe TournamentSnapshotImporter do
|
||||
describe '.import!' do
|
||||
it 'replaces follower tournament graph using source ids as stable mapping keys' do
|
||||
source = create(:group_stage_tournament, group_count: 2, match_factory: :filled_group_match)
|
||||
follower = create(:tournament, read_only_mode: true, sync_auth_token: 'shared-secret')
|
||||
|
||||
snapshot = TournamentSnapshotBuilder.build(source)
|
||||
|
||||
described_class.import!(tournament: follower, snapshot: snapshot)
|
||||
|
||||
follower.reload
|
||||
expect(follower.sync_source_tournament_id).to eq(source.id)
|
||||
expect(follower.name).to eq(source.name)
|
||||
expect(follower.teams.count).to eq(snapshot.fetch(:teams).count)
|
||||
expect(follower.stages.count).to eq(source.stages.count)
|
||||
expect(follower.matches.count).to eq(source.matches.count)
|
||||
expect(follower.teams.pluck(:sync_source_id)).to match_array(snapshot.fetch(:teams).map { |team| team.fetch(:id) })
|
||||
end
|
||||
end
|
||||
end
|
||||
97
tasks.py
97
tasks.py
|
|
@ -17,6 +17,8 @@ SERVER_PORT = "3000"
|
|||
TEST_BASE_URL = f"http://127.0.0.1:{SERVER_PORT}"
|
||||
BLACKBOX_BASE_URL = TEST_BASE_URL
|
||||
BLACKBOX_INTERNAL_BASE_URL = "http://app:3000"
|
||||
BLACKBOX_SOURCE_INTERNAL_BASE_URL = "http://source-app:3000"
|
||||
BLACKBOX_FOLLOWER_INTERNAL_BASE_URL = "http://follower-app:3000"
|
||||
|
||||
E2E_EMAIL = "e2e@example.com"
|
||||
E2E_PASSWORD = "password123"
|
||||
|
|
@ -31,6 +33,8 @@ BLACKBOX_COMPOSE_FILE = "docker-compose.blackbox.yml"
|
|||
BLACKBOX_PROJECT = "turniere-blackbox"
|
||||
BLACKBOX_POSTGRES_IMAGE = "postgres:16"
|
||||
BLACKBOX_DB_NAME = "turniere_blackbox"
|
||||
BLACKBOX_SOURCE_DB_NAME = "turniere_source_blackbox"
|
||||
BLACKBOX_FOLLOWER_DB_NAME = "turniere_follower_blackbox"
|
||||
BLACKBOX_DB_USER = "turniere"
|
||||
BLACKBOX_DB_PASSWORD = "turniere"
|
||||
BLACKBOX_SECRET_KEY_BASE = "turniere-blackbox-secret-key-base-please-change-in-real-prod"
|
||||
|
|
@ -269,8 +273,22 @@ def _shared_production_env():
|
|||
)
|
||||
|
||||
|
||||
def _production_env_for(db_host, db_name):
|
||||
return _env(
|
||||
RAILS_ENV="production",
|
||||
POSTGRES_URL=f"postgres://{BLACKBOX_DB_USER}:{BLACKBOX_DB_PASSWORD}@{db_host}:5432/{db_name}",
|
||||
POSTGRES_USERNAME=BLACKBOX_DB_USER,
|
||||
POSTGRES_PASSWORD=BLACKBOX_DB_PASSWORD,
|
||||
SECRET_KEY_BASE=BLACKBOX_SECRET_KEY_BASE,
|
||||
MAILGUN_API_KEY=BLACKBOX_MAILGUN_API_KEY,
|
||||
MAILGUN_DOMAIN=BLACKBOX_MAILGUN_DOMAIN,
|
||||
RAILS_LOG_TO_STDOUT="1",
|
||||
RAILS_SERVE_STATIC_FILES="1",
|
||||
)
|
||||
|
||||
|
||||
def _run_blackbox_rspec(base_url, email, password, username, alt_email=E2E_ALT_EMAIL, alt_password=E2E_ALT_PASSWORD,
|
||||
alt_username=E2E_ALT_USERNAME):
|
||||
alt_username=E2E_ALT_USERNAME, spec_path="spec/e2e/http", extra_env=None):
|
||||
env = _env(
|
||||
TURNIERE_E2E_BASE_URL=base_url,
|
||||
TURNIERE_E2E_EMAIL=email,
|
||||
|
|
@ -280,7 +298,9 @@ def _run_blackbox_rspec(base_url, email, password, username, alt_email=E2E_ALT_E
|
|||
TURNIERE_E2E_ALT_PASSWORD=alt_password,
|
||||
TURNIERE_E2E_ALT_USERNAME=alt_username,
|
||||
)
|
||||
command = ["bundle", "exec", "rspec", "spec/e2e/http", "--format", "documentation"]
|
||||
if extra_env:
|
||||
env.update(extra_env)
|
||||
command = ["bundle", "exec", "rspec", spec_path, "--format", "documentation"]
|
||||
print(_format_command(command))
|
||||
result = _run_subprocess(command, env=env, capture_output=True, check=False)
|
||||
if result.stdout:
|
||||
|
|
@ -329,6 +349,19 @@ def _stop_process(process, log_handle):
|
|||
log_handle.close()
|
||||
|
||||
|
||||
def _bootstrap_blackbox_users(c, env, email, password, username, alt_email, alt_password, alt_username):
|
||||
c.run(
|
||||
_bootstrap_user_command(email, password, username),
|
||||
env=env,
|
||||
pty=True,
|
||||
)
|
||||
c.run(
|
||||
_bootstrap_user_command(alt_email, alt_password, alt_username),
|
||||
env=env,
|
||||
pty=True,
|
||||
)
|
||||
|
||||
|
||||
@task
|
||||
def setup(c):
|
||||
"""Install the pinned Bundler and local backend gem dependencies."""
|
||||
|
|
@ -791,21 +824,61 @@ def blackbox_service(
|
|||
_wait_for_http(base_url=base_url, timeout=120)
|
||||
|
||||
_print_header("Bootstrapping confirmed E2E user")
|
||||
c.run(
|
||||
_bootstrap_user_command(email, password, username),
|
||||
env=_shared_production_env(),
|
||||
pty=True,
|
||||
)
|
||||
c.run(
|
||||
_bootstrap_user_command(alt_email, alt_password, alt_username),
|
||||
env=_shared_production_env(),
|
||||
pty=True,
|
||||
)
|
||||
_bootstrap_blackbox_users(c, _shared_production_env(), email, password, username, alt_email, alt_password, alt_username)
|
||||
|
||||
_print_header("Running HTTP E2E against production image")
|
||||
_run_blackbox_rspec(base_url, email, password, username, alt_email, alt_password, alt_username)
|
||||
|
||||
|
||||
@task(name="blackbox-follow-service")
|
||||
def blackbox_follow_service(
|
||||
c,
|
||||
source_base_url=BLACKBOX_SOURCE_INTERNAL_BASE_URL,
|
||||
follower_base_url=BLACKBOX_FOLLOWER_INTERNAL_BASE_URL,
|
||||
email=E2E_EMAIL,
|
||||
password=E2E_PASSWORD,
|
||||
username=E2E_USERNAME,
|
||||
alt_email=E2E_ALT_EMAIL,
|
||||
alt_password=E2E_ALT_PASSWORD,
|
||||
alt_username=E2E_ALT_USERNAME,
|
||||
):
|
||||
"""Run follower-sync HTTP E2E against two independent production app services."""
|
||||
source_env = _production_env_for("source-postgres", BLACKBOX_SOURCE_DB_NAME)
|
||||
follower_env = _production_env_for("follower-postgres", BLACKBOX_FOLLOWER_DB_NAME)
|
||||
|
||||
_print_header("Running source production database migrations")
|
||||
c.run("bundle exec rails db:migrate", env=source_env, pty=True)
|
||||
|
||||
_print_header("Running follower production database migrations")
|
||||
c.run("bundle exec rails db:migrate", env=follower_env, pty=True)
|
||||
|
||||
_print_header(f"Waiting for source app healthcheck at {source_base_url}")
|
||||
_wait_for_http(base_url=source_base_url, timeout=120)
|
||||
|
||||
_print_header(f"Waiting for follower app healthcheck at {follower_base_url}")
|
||||
_wait_for_http(base_url=follower_base_url, timeout=120)
|
||||
|
||||
_print_header("Bootstrapping confirmed E2E users on source and follower")
|
||||
_bootstrap_blackbox_users(c, source_env, email, password, username, alt_email, alt_password, alt_username)
|
||||
_bootstrap_blackbox_users(c, follower_env, email, password, username, alt_email, alt_password, alt_username)
|
||||
|
||||
_print_header("Running follower sync HTTP E2E against production images")
|
||||
_run_blackbox_rspec(
|
||||
source_base_url,
|
||||
email,
|
||||
password,
|
||||
username,
|
||||
alt_email,
|
||||
alt_password,
|
||||
alt_username,
|
||||
spec_path="spec/e2e/http/tournament_follow_sync_spec.rb",
|
||||
extra_env={
|
||||
"TURNIERE_SOURCE_E2E_BASE_URL": source_base_url,
|
||||
"TURNIERE_FOLLOWER_E2E_BASE_URL": follower_base_url,
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
@task(name="blackbox-production")
|
||||
def blackbox_production(
|
||||
c,
|
||||
|
|
|
|||
Loading…
Reference in New Issue