Add read-only tournament follower sync

This commit is contained in:
Daniel Schädler 2026-04-13 20:32:34 +02:00
parent 798eb56657
commit 133ebae847
22 changed files with 1084 additions and 21 deletions

View File

@ -84,3 +84,56 @@ rails e2e spec:
script: script:
- cd /app - cd /app
- inv blackbox-service - 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

View File

@ -30,6 +30,16 @@ class ApplicationController < ActionController::API
}, status: :forbidden }, status: :forbidden
end 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) def render_not_found_error(exception)
render json: { error: exception.to_s }, status: :not_found render json: { error: exception.to_s }, status: :not_found
end end

View File

@ -3,6 +3,7 @@
class BetsController < ApplicationController class BetsController < ApplicationController
before_action :set_match, only: %i[index create] before_action :set_match, only: %i[index create]
before_action :authenticate_user!, only: %i[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 rescue_from UserServiceError, with: :handle_user_service_error
def index def index

View File

@ -4,6 +4,7 @@ class MatchScoresController < ApplicationController
before_action :set_match_score, only: %i[show update] before_action :set_match_score, only: %i[show update]
before_action :authenticate_user!, only: %i[update] before_action :authenticate_user!, only: %i[update]
before_action -> { require_owner! @match_score.owner }, 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 # GET /scores/1
def show def show
@ -14,6 +15,7 @@ class MatchScoresController < ApplicationController
def update def update
if @match_score.update(match_score_params) if @match_score.update(match_score_params)
UpdateGroupsGroupScoresAndSave.call(group: @match_score.match.group) if @match_score.part_of_group_match? 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 render json: @match_score
else else
render json: @match_score.errors, status: :unprocessable_entity render json: @match_score.errors, status: :unprocessable_entity
@ -31,4 +33,10 @@ class MatchScoresController < ApplicationController
def match_score_params def match_score_params
params.slice(:points, :hidden_points).permit! params.slice(:points, :hidden_points).permit!
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 end

View File

@ -7,6 +7,8 @@ class MatchesController < ApplicationController
before_action -> { require_owner! @match.owner }, only: %i[update] before_action -> { require_owner! @match.owner }, only: %i[update]
before_action -> { require_owner! @group.owner }, only: %i[create] before_action -> { require_owner! @group.owner }, only: %i[create]
before_action :set_tournament, only: %i[index] before_action :set_tournament, only: %i[index]
before_action -> { require_writable_tournament!(@match.tournament) }, only: %i[update]
before_action -> { require_writable_tournament!(@group.tournament) }, only: %i[create]
# GET/tournaments/1/matches # GET/tournaments/1/matches
def index def index
@ -54,6 +56,7 @@ class MatchesController < ApplicationController
render json: match.errors, status: :unprocessable_entity render json: match.errors, status: :unprocessable_entity
elsif match.save elsif match.save
UpdateGroupsGroupScoresAndSave.call(group: @group) UpdateGroupsGroupScoresAndSave.call(group: @group)
push_sync_if_needed!(@group.tournament)
render json: match, include: %w[match_scores.team group], status: :created render json: match, include: %w[match_scores.team group], status: :created
else else
render json: match.errors, status: :unprocessable_entity render json: match.errors, status: :unprocessable_entity
@ -75,6 +78,7 @@ class MatchesController < ApplicationController
end end
render json: @match render json: @match
push_sync_if_needed!(@match.tournament)
else else
render json: @match.errors, status: :unprocessable_entity render json: @match.errors, status: :unprocessable_entity
raise ActiveRecord::Rollback raise ActiveRecord::Rollback
@ -156,4 +160,10 @@ class MatchesController < ApplicationController
end end
end 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 end

View File

@ -4,6 +4,7 @@ class StagesController < ApplicationController
before_action :set_stage, only: %i[show update] before_action :set_stage, only: %i[show update]
before_action :authenticate_user!, only: %i[update] before_action :authenticate_user!, only: %i[update]
before_action -> { require_owner! @stage.owner }, 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 # GET /stages/1
def show def show
@ -23,6 +24,7 @@ class StagesController < ApplicationController
handle_group_stage_end handle_group_stage_end
render json: @stage render json: @stage
push_sync_if_needed!(@stage.tournament)
else else
render json: @stage.errors, status: :unprocessable_entity render json: @stage.errors, status: :unprocessable_entity
raise ActiveRecord::Rollback raise ActiveRecord::Rollback
@ -75,4 +77,10 @@ class StagesController < ApplicationController
def stage_params def stage_params
params.slice(:state).permit! params.slice(:state).permit!
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 end

View File

@ -4,6 +4,7 @@ class TeamsController < ApplicationController
before_action :set_team, only: %i[show update] before_action :set_team, only: %i[show update]
before_action :authenticate_user!, only: %i[update] before_action :authenticate_user!, only: %i[update]
before_action -> { require_owner! @team.owner }, 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 # GET /teams/1
def show def show
@ -13,6 +14,7 @@ class TeamsController < ApplicationController
# PATCH/PUT /teams/1 # PATCH/PUT /teams/1
def update def update
if @team.update(team_params) if @team.update(team_params)
push_sync_if_needed!(@team.tournament)
render json: @team render json: @team
else else
render json: @team.errors, status: :unprocessable_entity render json: @team.errors, status: :unprocessable_entity
@ -28,4 +30,10 @@ class TeamsController < ApplicationController
def team_params def team_params
params.slice(:name).permit! params.slice(:name).permit!
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 end

View File

@ -1,12 +1,16 @@
# frozen_string_literal: true # frozen_string_literal: true
class TournamentsController < ApplicationController 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 :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 -> { require_owner! @tournament.owner }, only: %i[update destroy set_timer_end]
before_action :validate_create_params, only: %i[create] before_action :validate_create_params, only: %i[create]
before_action :validate_update_params, only: %i[update] before_action :validate_update_params, only: %i[update]
before_action :validate_set_timer_end_params, only: %i[set_timer_end] 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 rescue_from ActiveRecord::RecordNotFound, with: :render_not_found_error
# GET /tournaments # GET /tournaments
@ -37,11 +41,20 @@ class TournamentsController < ApplicationController
# POST /tournaments # POST /tournaments
def create def create
params = tournament_params params = tournament_params
params.require(:teams)
group_stage = params.delete(:group_stage) group_stage = params.delete(:group_stage)
teams = params.delete('teams') teams = params.delete('teams')
# create tournament tournament = current_user.tournaments.new params.except(:teams, :group_stage)
tournament = current_user.tournaments.new params
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 if group_stage
params.require(:playoff_teams_amount) params.require(:playoff_teams_amount)
groups = organize_teams_in_groups(teams) groups = organize_teams_in_groups(teams)
@ -81,6 +94,7 @@ class TournamentsController < ApplicationController
) )
end end
if @tournament.update(tournament_params) if @tournament.update(tournament_params)
push_sync_if_needed!(@tournament)
render json: @tournament render json: @tournament
else else
render json: @tournament.errors, status: :unprocessable_entity render json: @tournament.errors, status: :unprocessable_entity
@ -102,12 +116,20 @@ class TournamentsController < ApplicationController
# PATCH /tournaments/:id/set_timer_end # PATCH /tournaments/:id/set_timer_end
def set_timer_end def set_timer_end
if @tournament.update(timer_end_params) if @tournament.update(timer_end_params)
push_sync_if_needed!(@tournament)
render json: @tournament render json: @tournament
else else
render json: @tournament.errors, status: :unprocessable_entity render json: @tournament.errors, status: :unprocessable_entity
end end
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 private
@ -146,10 +168,16 @@ class TournamentsController < ApplicationController
end end
def tournament_params 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 end
def validate_create_params def validate_create_params
return if ActiveModel::Type::Boolean.new.cast(params[:read_only_mode])
teams = params['teams'] teams = params['teams']
return if teams.is_a?(Array) && teams.reject { |t| t.is_a? ActionController::Parameters }.count.zero? return if teams.is_a?(Array) && teams.reject { |t| t.is_a? ActionController::Parameters }.count.zero?
@ -163,12 +191,21 @@ class TournamentsController < ApplicationController
end end
def validate_update_params def validate_update_params
return if sync_config_only_update?
return if only_playoff_teams_amount_changed return if only_playoff_teams_amount_changed
playoff_teams_amount = params['playoff_teams_amount'].to_i || @tournament.playoff_teams_amount playoff_teams_amount = params.key?('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 instant_finalists_amount = if params.key?('instant_finalists_amount')
intermediate_round_participants_amount = params['intermediate_round_participants_amount'].to_i || 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 @tournament.intermediate_round_participants_amount
end
return if instant_finalists_amount + (intermediate_round_participants_amount / 2) == return if instant_finalists_amount + (intermediate_round_participants_amount / 2) ==
playoff_teams_amount 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' error: 'playoff_teams_amount, instant_finalists_amount and intermediate_round_participants_amount don\'t match'
}, status: :unprocessable_entity }, status: :unprocessable_entity
end 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 end
def validate_set_timer_end_params def validate_set_timer_end_params

View File

@ -6,6 +6,7 @@ class Group < ApplicationRecord
has_many :group_scores, dependent: :destroy has_many :group_scores, dependent: :destroy
delegate :owner, to: :stage delegate :owner, to: :stage
delegate :tournament, to: :stage
def teams def teams
matches.map(&:teams).flatten.uniq matches.map(&:teams).flatten.uniq

View File

@ -22,6 +22,10 @@ class Match < ApplicationRecord
stage ? stage.owner : group.owner stage ? stage.owner : group.owner
end end
def tournament
stage ? stage.tournament : group.stage.tournament
end
def current_leading_team def current_leading_team
return nil if match_scores.size < 2 return nil if match_scores.size < 2
return nil if score_value_for(match_scores.first) == score_value_for(match_scores.second) return nil if score_value_for(match_scores.first) == score_value_for(match_scores.second)

View File

@ -5,6 +5,7 @@ class MatchScore < ApplicationRecord
belongs_to :team belongs_to :team
delegate :owner, to: :match delegate :owner, to: :match
delegate :tournament, to: :match
def part_of_group_match? def part_of_group_match?
match.group_match? match.group_match?

View File

@ -9,6 +9,8 @@ class Tournament < ApplicationRecord
validates :name, presence: true validates :name, presence: true
validates :code, presence: true, uniqueness: 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 validate :playoff_teams_amount_is_positive_power_of_two
@ -32,6 +34,14 @@ class Tournament < ApplicationRecord
stages.find_by(level: -1) stages.find_by(level: -1)
end 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 private
def generate_code def generate_code
@ -49,4 +59,12 @@ class Tournament < ApplicationRecord
errors.add(:playoff_teams_amount, errors.add(:playoff_teams_amount,
'playoff_teams_amount needs to be a positive power of two') 'playoff_teams_amount needs to be a positive power of two')
end 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 end

View File

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

View File

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

View File

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

View File

@ -20,6 +20,7 @@ Rails.application.routes.draw do
member do member do
get :timer_end get :timer_end
patch :set_timer_end patch :set_timer_end
patch :sync_state
end end
end end
resources :match_scores, only: %i[show update] resources :match_scores, only: %i[show update]

View File

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

View File

@ -176,6 +176,25 @@ RSpec.describe TournamentsController, type: :controller do
apply_authentication_headers_for @user apply_authentication_headers_for @user
end 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 context 'with existing teams' do
it 'creates a new Tournament' do it 'creates a new Tournament' do
expect do expect do
@ -361,6 +380,32 @@ RSpec.describe TournamentsController, type: :controller do
expect(response.media_type).to eq('application/json') expect(response.media_type).to eq('application/json')
end 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 context 'any variable relevant for group stage to playoff transition changed' do
before(:each) do before(:each) do
@filled_tournament = create(:group_stage_tournament) @filled_tournament = create(:group_stage_tournament)
@ -494,6 +539,14 @@ RSpec.describe TournamentsController, type: :controller do
delete :destroy, params: { id: @tournament.to_param } delete :destroy, params: { id: @tournament.to_param }
end.to change(Team, :count).by(-@tournament.teams.size) end.to change(Team, :count).by(-@tournament.teams.size)
end 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 end
context 'as another user' do context 'as another user' do
@ -507,4 +560,53 @@ RSpec.describe TournamentsController, type: :controller do
end end
end 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 end

View File

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

View File

@ -27,5 +27,9 @@ RSpec.describe TournamentsController, type: :routing do
it 'routes to #destroy' do it 'routes to #destroy' do
expect(delete: '/tournaments/1').to route_to('tournaments#destroy', id: '1') expect(delete: '/tournaments/1').to route_to('tournaments#destroy', id: '1')
end end
it 'routes to #sync_state' do
expect(patch: '/tournaments/1/sync_state').to route_to('tournaments#sync_state', id: '1')
end
end end
end end

View File

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

View File

@ -17,6 +17,8 @@ SERVER_PORT = "3000"
TEST_BASE_URL = f"http://127.0.0.1:{SERVER_PORT}" TEST_BASE_URL = f"http://127.0.0.1:{SERVER_PORT}"
BLACKBOX_BASE_URL = TEST_BASE_URL BLACKBOX_BASE_URL = TEST_BASE_URL
BLACKBOX_INTERNAL_BASE_URL = "http://app:3000" 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_EMAIL = "e2e@example.com"
E2E_PASSWORD = "password123" E2E_PASSWORD = "password123"
@ -31,6 +33,8 @@ BLACKBOX_COMPOSE_FILE = "docker-compose.blackbox.yml"
BLACKBOX_PROJECT = "turniere-blackbox" BLACKBOX_PROJECT = "turniere-blackbox"
BLACKBOX_POSTGRES_IMAGE = "postgres:16" BLACKBOX_POSTGRES_IMAGE = "postgres:16"
BLACKBOX_DB_NAME = "turniere_blackbox" 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_USER = "turniere"
BLACKBOX_DB_PASSWORD = "turniere" BLACKBOX_DB_PASSWORD = "turniere"
BLACKBOX_SECRET_KEY_BASE = "turniere-blackbox-secret-key-base-please-change-in-real-prod" 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, 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( env = _env(
TURNIERE_E2E_BASE_URL=base_url, TURNIERE_E2E_BASE_URL=base_url,
TURNIERE_E2E_EMAIL=email, 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_PASSWORD=alt_password,
TURNIERE_E2E_ALT_USERNAME=alt_username, 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)) print(_format_command(command))
result = _run_subprocess(command, env=env, capture_output=True, check=False) result = _run_subprocess(command, env=env, capture_output=True, check=False)
if result.stdout: if result.stdout:
@ -329,6 +349,19 @@ def _stop_process(process, log_handle):
log_handle.close() 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 @task
def setup(c): def setup(c):
"""Install the pinned Bundler and local backend gem dependencies.""" """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) _wait_for_http(base_url=base_url, timeout=120)
_print_header("Bootstrapping confirmed E2E user") _print_header("Bootstrapping confirmed E2E user")
c.run( _bootstrap_blackbox_users(c, _shared_production_env(), email, password, username, alt_email, alt_password, alt_username)
_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,
)
_print_header("Running HTTP E2E against production image") _print_header("Running HTTP E2E against production image")
_run_blackbox_rspec(base_url, email, password, username, alt_email, alt_password, alt_username) _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") @task(name="blackbox-production")
def blackbox_production( def blackbox_production(
c, c,