From 133ebae8475af4c0cb3c61ef2dce4bedb8c4de62 Mon Sep 17 00:00:00 2001 From: Malaber Date: Mon, 13 Apr 2026 20:32:34 +0200 Subject: [PATCH] Add read-only tournament follower sync --- .gitlab-ci.yml | 53 ++++ app/controllers/application_controller.rb | 10 + app/controllers/bets_controller.rb | 1 + app/controllers/match_scores_controller.rb | 8 + app/controllers/matches_controller.rb | 10 + app/controllers/stages_controller.rb | 8 + app/controllers/teams_controller.rb | 8 + app/controllers/tournaments_controller.rb | 98 +++++- app/models/group.rb | 1 + app/models/match.rb | 4 + app/models/match_score.rb | 1 + app/models/tournament.rb | 18 ++ app/services/tournament_snapshot_builder.rb | 111 +++++++ app/services/tournament_snapshot_importer.rb | 170 +++++++++++ app/services/tournament_sync_pusher.rb | 53 ++++ config/routes.rb | 1 + ...60413100000_add_tournament_sync_support.rb | 38 +++ .../tournaments_controller_spec.rb | 102 +++++++ spec/e2e/http/tournament_follow_sync_spec.rb | 285 ++++++++++++++++++ spec/routing/tournaments_routing_spec.rb | 4 + .../tournament_snapshot_importer_spec.rb | 24 ++ tasks.py | 97 +++++- 22 files changed, 1084 insertions(+), 21 deletions(-) create mode 100644 app/services/tournament_snapshot_builder.rb create mode 100644 app/services/tournament_snapshot_importer.rb create mode 100644 app/services/tournament_sync_pusher.rb create mode 100644 db/migrate/20260413100000_add_tournament_sync_support.rb create mode 100644 spec/e2e/http/tournament_follow_sync_spec.rb create mode 100644 spec/services/tournament_snapshot_importer_spec.rb diff --git a/.gitlab-ci.yml b/.gitlab-ci.yml index 787fcea..705f6f2 100644 --- a/.gitlab-ci.yml +++ b/.gitlab-ci.yml @@ -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 diff --git a/app/controllers/application_controller.rb b/app/controllers/application_controller.rb index 4820771..b1bddc6 100644 --- a/app/controllers/application_controller.rb +++ b/app/controllers/application_controller.rb @@ -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 diff --git a/app/controllers/bets_controller.rb b/app/controllers/bets_controller.rb index 3a63c3e..876bf56 100644 --- a/app/controllers/bets_controller.rb +++ b/app/controllers/bets_controller.rb @@ -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 diff --git a/app/controllers/match_scores_controller.rb b/app/controllers/match_scores_controller.rb index f4d3014..1468cf9 100644 --- a/app/controllers/match_scores_controller.rb +++ b/app/controllers/match_scores_controller.rb @@ -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 diff --git a/app/controllers/matches_controller.rb b/app/controllers/matches_controller.rb index 1317ead..1a7bd67 100644 --- a/app/controllers/matches_controller.rb +++ b/app/controllers/matches_controller.rb @@ -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 diff --git a/app/controllers/stages_controller.rb b/app/controllers/stages_controller.rb index 800204d..c02dffc 100644 --- a/app/controllers/stages_controller.rb +++ b/app/controllers/stages_controller.rb @@ -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 diff --git a/app/controllers/teams_controller.rb b/app/controllers/teams_controller.rb index 522820d..42e9b25 100644 --- a/app/controllers/teams_controller.rb +++ b/app/controllers/teams_controller.rb @@ -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 diff --git a/app/controllers/tournaments_controller.rb b/app/controllers/tournaments_controller.rb index 85d1eb3..36a4612 100644 --- a/app/controllers/tournaments_controller.rb +++ b/app/controllers/tournaments_controller.rb @@ -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 || - @tournament.intermediate_round_participants_amount + 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 diff --git a/app/models/group.rb b/app/models/group.rb index b9bac6a..547a12b 100644 --- a/app/models/group.rb +++ b/app/models/group.rb @@ -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 diff --git a/app/models/match.rb b/app/models/match.rb index 9500c65..779c1b1 100644 --- a/app/models/match.rb +++ b/app/models/match.rb @@ -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) diff --git a/app/models/match_score.rb b/app/models/match_score.rb index 5bf815a..5d4cbc1 100644 --- a/app/models/match_score.rb +++ b/app/models/match_score.rb @@ -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? diff --git a/app/models/tournament.rb b/app/models/tournament.rb index 1f02015..fef4226 100644 --- a/app/models/tournament.rb +++ b/app/models/tournament.rb @@ -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 diff --git a/app/services/tournament_snapshot_builder.rb b/app/services/tournament_snapshot_builder.rb new file mode 100644 index 0000000..f516a30 --- /dev/null +++ b/app/services/tournament_snapshot_builder.rb @@ -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 diff --git a/app/services/tournament_snapshot_importer.rb b/app/services/tournament_snapshot_importer.rb new file mode 100644 index 0000000..ef75fcb --- /dev/null +++ b/app/services/tournament_snapshot_importer.rb @@ -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 diff --git a/app/services/tournament_sync_pusher.rb b/app/services/tournament_sync_pusher.rb new file mode 100644 index 0000000..bb110bb --- /dev/null +++ b/app/services/tournament_sync_pusher.rb @@ -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 diff --git a/config/routes.rb b/config/routes.rb index 0351e81..a1b4021 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -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] diff --git a/db/migrate/20260413100000_add_tournament_sync_support.rb b/db/migrate/20260413100000_add_tournament_sync_support.rb new file mode 100644 index 0000000..b421dda --- /dev/null +++ b/db/migrate/20260413100000_add_tournament_sync_support.rb @@ -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 diff --git a/spec/controllers/tournaments_controller_spec.rb b/spec/controllers/tournaments_controller_spec.rb index 92f3597..f3080e0 100644 --- a/spec/controllers/tournaments_controller_spec.rb +++ b/spec/controllers/tournaments_controller_spec.rb @@ -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 diff --git a/spec/e2e/http/tournament_follow_sync_spec.rb b/spec/e2e/http/tournament_follow_sync_spec.rb new file mode 100644 index 0000000..f4e417b --- /dev/null +++ b/spec/e2e/http/tournament_follow_sync_spec.rb @@ -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 diff --git a/spec/routing/tournaments_routing_spec.rb b/spec/routing/tournaments_routing_spec.rb index f8d1acf..00bda66 100644 --- a/spec/routing/tournaments_routing_spec.rb +++ b/spec/routing/tournaments_routing_spec.rb @@ -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 diff --git a/spec/services/tournament_snapshot_importer_spec.rb b/spec/services/tournament_snapshot_importer_spec.rb new file mode 100644 index 0000000..0f4b197 --- /dev/null +++ b/spec/services/tournament_snapshot_importer_spec.rb @@ -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 diff --git a/tasks.py b/tasks.py index dbb9e73..366e73f 100644 --- a/tasks.py +++ b/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,