diff --git a/app/controllers/tournaments_controller.rb b/app/controllers/tournaments_controller.rb index a95b245..e1e350e 100644 --- a/app/controllers/tournaments_controller.rb +++ b/app/controllers/tournaments_controller.rb @@ -12,9 +12,9 @@ class TournamentsController < ApplicationController end before_action :set_tournament_for_show, only: %i[show] - before_action :set_tournament, only: %i[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 :set_tournament, only: %i[update destroy set_timer_end timer_end sync_state test_sync] + before_action :authenticate_user!, only: %i[create update destroy set_timer_end test_sync] + before_action -> { require_owner! @tournament.owner }, only: %i[update destroy set_timer_end test_sync] 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] @@ -147,6 +147,23 @@ class TournamentsController < ApplicationController render json: { error: e.message }, status: :unprocessable_entity end + def test_sync + unless @tournament.sync_push_enabled? + return render json: { + error: 'Tournament sync test requires writable tournament with sync_target_url and sync_auth_token' + }, status: :unprocessable_entity + end + + TournamentSyncPusher.push!(@tournament) + render json: @tournament.reload, include: '**' + rescue TournamentSyncPusher::SyncFailed => e + @tournament.reload + render json: { + error: e.message, + tournament: TournamentSerializer.new(@tournament).as_json + }, status: :bad_gateway + end + private diff --git a/config/routes.rb b/config/routes.rb index f4170ac..fdb467b 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -26,6 +26,7 @@ Rails.application.routes.draw do get :timer_end patch :set_timer_end patch :sync_state + post :test_sync end end resources :match_scores, only: %i[show update] diff --git a/spec/controllers/tournaments_controller_spec.rb b/spec/controllers/tournaments_controller_spec.rb index 24d2fc2..9bc9c39 100644 --- a/spec/controllers/tournaments_controller_spec.rb +++ b/spec/controllers/tournaments_controller_spec.rb @@ -102,7 +102,11 @@ RSpec.describe TournamentsController, type: :controller do get :show, params: { id: @tournament.to_param } json = deserialize_response(response) expect(json[:id].to_i).to eq(@tournament.id) - expected_keys = %i[id name code public description playoff_teams_amount instant_finalists_amount intermediate_round_participants_amount timer_end owner_username stages teams] + expected_keys = %i[ + id name code public description playoff_teams_amount instant_finalists_amount + intermediate_round_participants_amount read_only_mode sync_target_url + sync_last_push_error sync_last_pushed_at timer_end owner_username stages teams + ] expect(json.keys).to match_array(expected_keys) expect(json).to eq(TournamentSerializer.new(@tournament).as_json) expect(json[:name]).to eq(@tournament.name) @@ -110,6 +114,24 @@ RSpec.describe TournamentsController, type: :controller do expect(json[:public]).to eq(@tournament.public) end + it 'returns sync metadata on full tournament payload' do + pushed_at = Time.utc(2026, 4, 20, 12, 0, 0) + @tournament.update!( + read_only_mode: true, + sync_auth_token: 'shared-secret', + sync_last_pushed_at: pushed_at, + sync_last_push_error: 'push failed' + ) + + get :show, params: { id: @tournament.to_param } + + json = deserialize_response(response) + expect(json[:read_only_mode]).to eq(true) + expect(json[:sync_last_pushed_at]).to eq(pushed_at.iso8601) + expect(json[:sync_last_push_error]).to eq('push failed') + expect(json).not_to have_key(:sync_auth_token) + end + context 'with simple=true parameter' do it 'returns no relations' do get :show, params: { id: @tournament.to_param, simple: 'true' } @@ -681,4 +703,60 @@ RSpec.describe TournamentsController, type: :controller do expect(deserialize_response(response)[:error]).to eq('Invalid sync token') end end + + describe 'POST #test_sync' do + before do + apply_authentication_headers_for @tournament.owner + end + + it 'pushes snapshot immediately and returns updated tournament' do + @tournament.update!( + sync_target_url: 'https://remote.example.com/tournaments/1/sync_state', + sync_auth_token: 'shared-secret' + ) + pushed_at = Time.zone.parse('2026-04-20T14:00:00Z') + + allow(TournamentSyncPusher).to receive(:push!) do |tournament| + tournament.update_columns(sync_last_pushed_at: pushed_at, sync_last_push_error: nil) + true + end + + post :test_sync, params: { id: @tournament.to_param } + + expect(response).to have_http_status(:ok) + json = deserialize_response(response) + expect(json[:id]).to eq(@tournament.id) + expect(json[:sync_last_pushed_at]).to eq(pushed_at.iso8601) + expect(json[:sync_last_push_error]).to be_nil + expect(TournamentSyncPusher).to have_received(:push!).with(@tournament) + end + + it 'rejects sync test without complete leader configuration' do + post :test_sync, params: { id: @tournament.to_param } + + expect(response).to have_http_status(:unprocessable_entity) + expect(deserialize_response(response)[:error]) + .to eq('Tournament sync test requires writable tournament with sync_target_url and sync_auth_token') + end + + it 'returns sync failure details when push fails' do + @tournament.update!( + sync_target_url: 'https://remote.example.com/tournaments/1/sync_state', + sync_auth_token: 'shared-secret' + ) + @tournament.update_columns(sync_last_push_error: 'old error') + + allow(TournamentSyncPusher).to receive(:push!) do |tournament| + tournament.update_columns(sync_last_push_error: 'connection refused') + raise TournamentSyncPusher::SyncFailed, 'connection refused' + end + + post :test_sync, params: { id: @tournament.to_param } + + expect(response).to have_http_status(:bad_gateway) + json = deserialize_response(response) + expect(json[:error]).to eq('connection refused') + expect(json.dig(:tournament, :sync_last_push_error)).to eq('connection refused') + end + end end diff --git a/spec/e2e/http/tournament_sync_test_spec.rb b/spec/e2e/http/tournament_sync_test_spec.rb new file mode 100644 index 0000000..7199838 --- /dev/null +++ b/spec/e2e/http/tournament_sync_test_spec.rb @@ -0,0 +1,105 @@ +# frozen_string_literal: true + +require 'spec_helper' +require 'securerandom' +require_relative '../../../e2e/lib/api_client' + +RSpec.describe 'Tournament sync test HTTP E2E' do + required_env_vars = %w[ + TURNIERE_E2E_BASE_URL + TURNIERE_E2E_EMAIL + TURNIERE_E2E_PASSWORD + ].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(:base_url) { ENV.fetch('TURNIERE_E2E_BASE_URL') } + let(:owner_email) { ENV.fetch('TURNIERE_E2E_EMAIL') } + let(:owner_password) { ENV.fetch('TURNIERE_E2E_PASSWORD') } + let(:sync_token) { "sync-test-#{SecureRandom.hex(6)}" } + let(:client) { login_client } + let(:anonymous_client) { TurniereE2E::ApiClient.new(base_url: base_url) } + + it 'roundtrips current snapshot into follower tournament through test_sync endpoint' do + leader = create_group_stage_tournament(name_prefix: 'Sync Test Leader') + follower = create_follower_tournament(name_prefix: 'Sync Test Follower') + + configure = client.patch("/tournaments/#{leader.fetch(:id)}", body: { + sync_target_url: "#{base_url}/tournaments/#{follower.fetch(:id)}/sync_state", + sync_auth_token: sync_token + }) + expect(configure[:status]).to eq(200) + + rename = client.patch("/tournaments/#{leader.fetch(:id)}", body: { + name: "Leader Renamed #{SecureRandom.hex(3)}", + description: 'Roundtrip sync test' + }) + expect(rename[:status]).to eq(200) + + before_follower = fetch_tournament(follower.fetch(:id)) + expect(before_follower.fetch(:teams)).to be_empty + + test_sync = client.post("/tournaments/#{leader.fetch(:id)}/test_sync") + expect(test_sync[:status]).to eq(200) + expect(test_sync.dig(:json, :sync_last_pushed_at)).not_to be_nil + expect(test_sync.dig(:json, :sync_last_push_error)).to be_nil + + after_leader = fetch_tournament(leader.fetch(:id)) + after_follower = fetch_tournament(follower.fetch(:id)) + + expect(after_follower.fetch(:read_only_mode)).to eq(true) + expect(after_follower.fetch(:name)).to eq(after_leader.fetch(:name)) + expect(after_follower.fetch(:description)).to eq(after_leader.fetch(:description)) + expect(after_follower.fetch(:teams).map { |team| team.fetch(:name) }) + .to match_array(after_leader.fetch(:teams).map { |team| team.fetch(:name) }) + end + + def login_client + api_client = TurniereE2E::ApiClient.new(base_url: base_url) + response = api_client.login!(email: owner_email, password: owner_password) + + expect(response[:status]).to eq(200) + api_client + end + + def create_group_stage_tournament(name_prefix:) + response = client.post('/tournaments', body: { + name: "#{name_prefix} #{SecureRandom.hex(3)}", + description: 'Leader tournament for sync test endpoint', + public: false, + group_stage: true, + playoff_teams_amount: 4, + teams: 2.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 + }) + expect(response[:status]).to eq(201) + fetch_tournament(response.dig(:json, :id)) + end + + def create_follower_tournament(name_prefix:) + response = client.post('/tournaments', body: { + name: "#{name_prefix} #{SecureRandom.hex(3)}", + description: 'Follower tournament for sync test endpoint', + public: true, + read_only_mode: true, + sync_auth_token: sync_token + }) + expect(response[:status]).to eq(201) + fetch_tournament(response.dig(:json, :id)) + end + + def fetch_tournament(tournament_id) + response = anonymous_client.get("/tournaments/#{tournament_id}") + expect(response[:status]).to eq(200) + response.fetch(:json) + end +end diff --git a/spec/routing/tournaments_routing_spec.rb b/spec/routing/tournaments_routing_spec.rb index 00bda66..8c30c0d 100644 --- a/spec/routing/tournaments_routing_spec.rb +++ b/spec/routing/tournaments_routing_spec.rb @@ -28,8 +28,12 @@ RSpec.describe TournamentsController, type: :routing 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 + it 'routes to #sync_state' do + expect(patch: '/tournaments/1/sync_state').to route_to('tournaments#sync_state', id: '1') + end + + it 'routes to #test_sync' do + expect(post: '/tournaments/1/test_sync').to route_to('tournaments#test_sync', id: '1') end end +end diff --git a/tasks.py b/tasks.py index f50e5f3..6fd9f3d 100644 --- a/tasks.py +++ b/tasks.py @@ -647,10 +647,14 @@ def test_http_e2e( alt_email=E2E_ALT_EMAIL, alt_password=E2E_ALT_PASSWORD, alt_username=E2E_ALT_USERNAME, + spec="", ): """Run HTTP E2E specs against an already running backend.""" + command = f"{_bundle_exec('rspec')} spec/e2e/http" + if spec: + command = f"{_bundle_exec('rspec')} {shlex.quote(spec)}" c.run( - f"{_bundle_exec('rspec')} spec/e2e/http", + command, env=_env( TURNIERE_E2E_BASE_URL=base_url, TURNIERE_E2E_EMAIL=email, @@ -675,6 +679,7 @@ def verify_http( alt_password=E2E_ALT_PASSWORD, alt_username=E2E_ALT_USERNAME, reset_db=True, + spec="", ): """Boot a local test server, then run the HTTP E2E suite against it.""" if reset_db: @@ -698,6 +703,7 @@ def verify_http( alt_email=alt_email, alt_password=alt_password, alt_username=alt_username, + spec=spec, ) finally: _stop_process(process, log_handle)