From 71be61791a314ec749ec8742cd9f0eebe9edee65 Mon Sep 17 00:00:00 2001 From: Malaber Date: Sat, 11 Apr 2026 13:12:49 +0200 Subject: [PATCH 01/18] Add backend lifecycle E2E coverage --- .gitlab-ci.yml | 14 + spec/e2e_known_behaviors.md | 28 ++ .../requests/e2e/tournament_lifecycle_spec.rb | 298 ++++++++++++++++++ 3 files changed, 340 insertions(+) create mode 100644 spec/e2e_known_behaviors.md create mode 100644 spec/requests/e2e/tournament_lifecycle_spec.rb diff --git a/.gitlab-ci.yml b/.gitlab-ci.yml index 3b5f8bf..be344e8 100644 --- a/.gitlab-ci.yml +++ b/.gitlab-ci.yml @@ -24,3 +24,17 @@ rails spec: paths: - specs_with_runtime.txt expire_in: 30 days + +rails e2e spec: + stage: test + image: $CI_REGISTRY_IMAGE/test/commits:$CI_COMMIT_SHA + rules: + - if: $SKIP_TEST + when: never + - if: $CI_PIPELINE_SOURCE != "push" + when: never + - when: always + script: + - cd /app + - bundle exec rails db:migrate + - bundle exec rspec spec/requests/e2e diff --git a/spec/e2e_known_behaviors.md b/spec/e2e_known_behaviors.md new file mode 100644 index 0000000..d1e014e --- /dev/null +++ b/spec/e2e_known_behaviors.md @@ -0,0 +1,28 @@ +# Backend E2E Known Behaviors + +This file records non-obvious backend behaviors that the E2E suite intentionally preserves. + +## Group-stage tournaments cannot finish the playoff final through the normal match endpoint + +Observed and covered by `spec/requests/e2e/tournament_lifecycle_spec.rb`: + +- in tournaments that started with a group stage, the playoff final can be started and scored +- but `PATCH /matches/:id` with `state=finished` on that final returns: + - status `422` + - error `"Moving Team one stage down failed"` +- the final remains `in_progress` + +This appears to happen because playoff stages count down to level `0`, while the old group stage still exists at level `-1`. The final-finish flow looks for a "stage below" using `current_stage.level - 1`, which resolves to the group stage for finals in group-stage tournaments. The E2E suite preserves this current behavior instead of changing backend code. + +## Group stage to playoff conversion with 3 groups and `playoff_teams_amount = 4` + +Observed and covered by `spec/requests/e2e/tournament_lifecycle_spec.rb`: + +- `instant_finalists_amount` becomes `3` +- `intermediate_round_participants_amount` becomes `2` +- this means `5` teams advance out of the group stage into the playoff tree +- the resulting playoff entry stage is an `intermediate_stage` with: + - `3` `single_team` matches + - `1` regular match + +This can look surprising if `playoff_teams_amount = 4` is read as "exactly four teams leave the group stage". The current backend instead models "four playoff slots after the intermediate round", which is the behavior the new E2E tests lock in. diff --git a/spec/requests/e2e/tournament_lifecycle_spec.rb b/spec/requests/e2e/tournament_lifecycle_spec.rb new file mode 100644 index 0000000..3eb1406 --- /dev/null +++ b/spec/requests/e2e/tournament_lifecycle_spec.rb @@ -0,0 +1,298 @@ +# frozen_string_literal: true + +require 'rails_helper' + +RSpec.describe 'Tournament lifecycle E2E', type: :request do + def parsed_response + JSON.parse(response.body, symbolize_names: true) + end + + def auth_headers_for(user) + user.create_new_auth_token.merge( + 'ACCEPT' => 'application/json' + ) + end + + def create_tournament!(user:, params:) + post '/tournaments', params:, headers: auth_headers_for(user), as: :json + + expect(response).to have_http_status(:created) + Tournament.find(parsed_response[:id]) + end + + def update_timer_by_seconds!(tournament, user, seconds) + patch "/tournaments/#{tournament.id}/set_timer_end", + params: { timer_end_seconds: seconds }, + headers: auth_headers_for(user), + as: :json + + expect(response).to have_http_status(:ok) + parsed_response + end + + def group_identifier_for(team) + team.name[/G(\d+)/, 1].to_i + end + + def seed_identifier_for(team) + team.name[/S(\d+)/, 1].to_i + end + + def playoff_rank_for(team) + [seed_identifier_for(team), group_identifier_for(team), team.name] + end + + def preferred_winner_for(match) + match.teams.min_by { |team| playoff_rank_for(team) } + end + + def play_match!(match, user:, winner: preferred_winner_for(match), winning_points: 10, losing_points: 1, + expected_finish_status: 200, expected_finish_error: nil) + patch "/matches/#{match.id}", + params: { state: 'in_progress' }, + headers: auth_headers_for(user), + as: :json + expect(response.status).to eq(200), "starting match #{match.id} failed: #{response.body}" + + ordered_scores = match.reload.match_scores.sort_by(&:id) + ordered_scores.each do |match_score| + points = match_score.team == winner ? winning_points : losing_points + patch "/match_scores/#{match_score.id}", + params: { points: }, + headers: auth_headers_for(user), + as: :json + expect(response.status).to eq(200), "updating match_score #{match_score.id} failed: #{response.body}" + end + + patch "/matches/#{match.id}", + params: { state: 'finished' }, + headers: auth_headers_for(user), + as: :json + expect(response.status).to eq( + expected_finish_status + ), "finishing match #{match.id} with teams #{match.reload.teams.map(&:name)} failed: #{response.body}" + return if expected_finish_error.nil? + + expect(parsed_response[:error]).to eq(expected_finish_error) + end + + def finish_group_stage_matches!(group_stage, user:) + group_stage.groups.order(:number).each do |group| + group.matches.sort_by(&:position).each do |match| + winner = match.teams.min_by { |team| [seed_identifier_for(team), team.name] } + play_match!(match, user:, winner:) + end + end + end + + def finish_stage!(stage, user:) + patch "/stages/#{stage.id}", + params: { state: 'finished' }, + headers: auth_headers_for(user), + as: :json + + expect(response).to have_http_status(:ok) + end + + def run_playoff_to_completion!(tournament, user:, include_final: true) + loop do + excluded_levels = [-1] + excluded_levels << 0 unless include_final + + playable_matches = tournament.reload.stages + .reject { |stage| excluded_levels.include?(stage.level) } + .flat_map(&:matches) + .select(&:not_started?) + .sort_by { |match| [-match.stage.level, match.position] } + + break if playable_matches.empty? + + playable_matches.each do |match| + play_match!(match, user:) + end + end + + tournament.reload + end + + def create_group_stage_payload(group_count:, teams_per_group:, playoff_teams_amount:) + teams = group_count.times.flat_map do |group_index| + teams_per_group.times.map do |seed_index| + { + name: "G#{group_index + 1}-S#{seed_index + 1}", + group: group_index + } + end + end + + { + name: "Group stage #{group_count}x#{teams_per_group}", + description: 'Lifecycle E2E tournament', + public: false, + group_stage: true, + playoff_teams_amount:, + teams: + } + end + + def create_playoff_only_payload(team_count:) + { + name: "Playoff only #{team_count}", + description: 'Lifecycle E2E tournament', + public: false, + teams: team_count.times.map { |index| { name: "G0-S#{index + 1}" } } + } + end + + let(:user) { create(:user) } + + describe 'group stage lifecycle with four teams per group' do + it 'creates a 16-team tournament, finishes groups, generates playoffs, and reaches a champion' do + tournament = create_tournament!( + user:, + params: create_group_stage_payload(group_count: 4, teams_per_group: 4, playoff_teams_amount: 8) + ) + + expect(tournament.playoff_teams_amount).to eq(8) + expect(tournament.instant_finalists_amount).to eq(8) + expect(tournament.intermediate_round_participants_amount).to eq(0) + + group_stage = tournament.stages.find_by(level: -1) + expect(group_stage).to be_present + expect(group_stage.groups.count).to eq(4) + expect(group_stage.groups.map(&:number)).to match_array([1, 2, 3, 4]) + expect(group_stage.groups.map { |group| group.matches.count }).to all(eq(6)) + + get "/tournaments/#{tournament.id}/matches", + params: { state: 'upcoming' }, + headers: { 'ACCEPT' => 'application/json' } + expect(response).to have_http_status(:ok) + expect(parsed_response.count).to eq(4) + expect(parsed_response.map { |match| match[:position] }).to all(eq(0)) + + timer_response = update_timer_by_seconds!(tournament, user, 600) + expect(Time.zone.parse(timer_response[:timer_end])).to be > Time.zone.now + + get "/tournaments/#{tournament.id}/timer_end", headers: { 'ACCEPT' => 'application/json' } + expect(response).to have_http_status(:ok) + expect(Time.zone.parse(parsed_response[:timer_end])).to be > Time.zone.now + + finish_group_stage_matches!(group_stage, user:) + + get "/tournaments/#{tournament.id}/statistics", headers: { 'ACCEPT' => 'application/json' } + expect(response).to have_http_status(:ok) + expect(parsed_response[:group_scores].count).to eq(16) + expect(parsed_response[:most_dominant_score]).to be_present + expect(parsed_response[:least_dominant_score]).to be_present + + advancing_teams_before_finish = tournament.reload.teams_advancing_from_group_stage + expect(advancing_teams_before_finish.map(&:name)).to eq( + %w[G1-S1 G3-S2 G2-S1 G4-S2 G3-S1 G1-S2 G4-S1 G2-S2] + ) + + finish_stage!(group_stage, user:) + + tournament.reload + playoff_entry_stage = tournament.stages.where.not(level: -1).max_by(&:level) + expect(playoff_entry_stage.matches.count).to eq(4) + expect(playoff_entry_stage.matches.sort_by(&:position).map { |match| match.teams.map(&:name) }).to eq( + [ + %w[G1-S1 G3-S2], + %w[G2-S1 G4-S2], + %w[G3-S1 G1-S2], + %w[G4-S1 G2-S2] + ] + ) + + run_playoff_to_completion!(tournament, user:, include_final: false) + + final_stage = tournament.stages.find_by(level: 0) + final_match = final_stage.matches.first + expect(final_match).to be_not_started + expect(final_match.teams.map(&:name)).to match_array(%w[G1-S1 G3-S1]) + + play_match!( + final_match, + user:, + expected_finish_status: 422, + expected_finish_error: 'Moving Team one stage down failed' + ) + + final_match.reload + expect(final_match).to be_in_progress + expect(final_match.current_leading_team.name).to eq('G1-S1') + end + + it 'preserves the current intermediate-round behavior for three groups advancing into a four-team playoff' do + tournament = create_tournament!( + user:, + params: create_group_stage_payload(group_count: 3, teams_per_group: 4, playoff_teams_amount: 4) + ) + + expect(tournament.playoff_teams_amount).to eq(4) + expect(tournament.instant_finalists_amount).to eq(3) + expect(tournament.intermediate_round_participants_amount).to eq(2) + + group_stage = tournament.stages.find_by(level: -1) + finish_group_stage_matches!(group_stage, user:) + finish_stage!(group_stage, user:) + + tournament.reload + playoff_entry_stage = tournament.stages.where.not(level: -1).max_by(&:level) + expect(playoff_entry_stage).to be_intermediate_stage + expect(playoff_entry_stage.matches.count).to eq(4) + expect(playoff_entry_stage.matches.count(&:single_team?)).to eq(3) + expect(playoff_entry_stage.matches.count(&:not_started?)).to eq(1) + + advancing_team_names = playoff_entry_stage.matches.flat_map { |match| match.teams.map(&:name) } + expect(advancing_team_names.uniq.count).to eq(5) + expect(advancing_team_names).to include('G1-S1', 'G2-S1', 'G3-S1') + + run_playoff_to_completion!(tournament, user:, include_final: false) + + final_match = tournament.stages.find_by(level: 0).matches.first + expect(final_match).to be_not_started + expect(final_match.teams.map(&:name)).to match_array(%w[G3-S1 G1-S1]) + + play_match!( + final_match, + user:, + expected_finish_status: 422, + expected_finish_error: 'Moving Team one stage down failed' + ) + + final_match.reload + expect(final_match).to be_in_progress + expect(final_match.current_leading_team.name).to eq('G1-S1') + end + end + + describe 'playoff-only lifecycle across different team counts' do + [2, 3, 5, 8].each do |team_count| + it "plays a #{team_count}-team playoff tournament through to the end" do + tournament = create_tournament!( + user:, + params: create_playoff_only_payload(team_count:) + ) + + opening_stage = tournament.stages.max_by(&:level) + expect(opening_stage).to be_present + expect(opening_stage.matches).not_to be_empty + + if Utils.po2?(team_count) + expect(opening_stage).to be_playoff_stage + else + expect(opening_stage).to be_intermediate_stage + expect(opening_stage.matches.count(&:single_team?)).to be > 0 + end + + run_playoff_to_completion!(tournament, user:) + + tournament.reload + final_match = tournament.stages.find_by(level: 0).matches.first + expect(final_match).to be_finished + expect(final_match.winner.name).to eq('G0-S1') + end + end + end +end From 39ba1cff225eac3f98bd28601b051be54e1d236a Mon Sep 17 00:00:00 2001 From: Malaber Date: Sat, 11 Apr 2026 13:40:12 +0200 Subject: [PATCH 02/18] Run backend E2E over HTTP --- .gitlab-ci.yml | 9 +- e2e/lib/api_client.rb | 123 ++++++ e2e/lib/scenario_runner.rb | 390 ++++++++++++++++++ script/e2e_scenarios.rb | 47 +++ script/wait_for_http.rb | 11 + spec/e2e/http/tournament_lifecycle_spec.rb | 64 +++ spec/e2e_known_behaviors.md | 22 +- .../requests/e2e/tournament_lifecycle_spec.rb | 298 ------------- 8 files changed, 663 insertions(+), 301 deletions(-) create mode 100644 e2e/lib/api_client.rb create mode 100644 e2e/lib/scenario_runner.rb create mode 100644 script/e2e_scenarios.rb create mode 100644 script/wait_for_http.rb create mode 100644 spec/e2e/http/tournament_lifecycle_spec.rb delete mode 100644 spec/requests/e2e/tournament_lifecycle_spec.rb diff --git a/.gitlab-ci.yml b/.gitlab-ci.yml index be344e8..356e859 100644 --- a/.gitlab-ci.yml +++ b/.gitlab-ci.yml @@ -37,4 +37,11 @@ rails e2e spec: script: - cd /app - bundle exec rails db:migrate - - bundle exec rspec spec/requests/e2e + - bundle exec rails runner -e test "user = User.find_or_initialize_by(email: 'e2e@example.com'); user.username = 'e2e-user'; user.password = 'password123'; user.password_confirmation = 'password123'; user.confirmed_at = Time.current; user.uid = user.email; user.provider = 'email'; user.save!" + - bundle exec rails s -e test -p 3000 -b 0.0.0.0 > /tmp/turniere-e2e-server.log 2>&1 & + - SERVER_PID=$! + - ruby script/wait_for_http.rb http://127.0.0.1:3000 60 + - TURNIERE_E2E_BASE_URL=http://127.0.0.1:3000 TURNIERE_E2E_EMAIL=e2e@example.com TURNIERE_E2E_PASSWORD=password123 TURNIERE_E2E_USERNAME=e2e-user bundle exec rspec spec/e2e/http + - kill $SERVER_PID + after_script: + - test -f /tmp/turniere-e2e-server.log && tail -n 200 /tmp/turniere-e2e-server.log || true diff --git a/e2e/lib/api_client.rb b/e2e/lib/api_client.rb new file mode 100644 index 0000000..2c195a4 --- /dev/null +++ b/e2e/lib/api_client.rb @@ -0,0 +1,123 @@ +# frozen_string_literal: true + +require 'json' +require 'net/http' +require 'uri' + +module TurniereE2E + class ApiError < StandardError + attr_reader :status, :body + + def initialize(message, status:, body:) + super(message) + @status = status + @body = body + end + end + + class ApiClient + attr_reader :base_url + + def initialize(base_url:) + @base_url = base_url.sub(%r{/\z}, '') + @auth_headers = {} + end + + def register!(email:, password:, username:) + response = request( + :post, + '/users', + body: { + email: email, + password: password, + password_confirmation: password, + username: username + } + ) + store_auth_headers!(response) + response.fetch(:json) + end + + def login!(email:, password:) + response = request( + :post, + '/users/sign_in', + body: { + email: email, + password: password + } + ) + store_auth_headers!(response) + response + end + + def authenticated? + !@auth_headers.empty? + end + + def get(path, params: nil, headers: {}) + request(:get, path, params: params, headers: headers) + end + + def post(path, body: nil, params: nil, headers: {}) + request(:post, path, body: body, params: params, headers: headers) + end + + def patch(path, body: nil, params: nil, headers: {}) + request(:patch, path, body: body, params: params, headers: headers) + end + + private + + def request(method, path, body: nil, params: nil, headers: {}) + uri = URI.parse("#{base_url}#{path}") + uri.query = URI.encode_www_form(params) if params && !params.empty? + + request_class = case method + when :get then Net::HTTP::Get + when :post then Net::HTTP::Post + when :patch then Net::HTTP::Patch + else + raise ArgumentError, "unsupported method #{method}" + end + + request = request_class.new(uri) + merged_headers(headers).each { |key, value| request[key] = value } + request.body = JSON.dump(body) if body + + response = Net::HTTP.start(uri.host, uri.port, use_ssl: uri.scheme == 'https') do |http| + http.request(request) + end + + parsed_json = parse_json_body(response.body) + { + status: response.code.to_i, + json: parsed_json, + headers: response.each_header.to_h + } + end + + def merged_headers(headers) + { + 'accept' => 'application/json', + 'content-type' => 'application/json' + }.merge(@auth_headers).merge(headers.transform_keys(&:downcase)) + end + + def parse_json_body(body) + return {} if body.nil? || body.strip.empty? + + JSON.parse(body, symbolize_names: true) + rescue JSON::ParserError + { raw_body: body } + end + + def store_auth_headers!(response) + %w[access-token client uid].each_with_object({}) do |key, auth_headers| + auth_headers[key] = response[:headers][key] if response[:headers].key?(key) + end.tap do |headers| + @auth_headers = headers unless headers.empty? + end + end + end +end diff --git a/e2e/lib/scenario_runner.rb b/e2e/lib/scenario_runner.rb new file mode 100644 index 0000000..7da46d5 --- /dev/null +++ b/e2e/lib/scenario_runner.rb @@ -0,0 +1,390 @@ +# frozen_string_literal: true + +require_relative 'api_client' +require 'securerandom' +require 'time' + +module TurniereE2E + class ScenarioRunner + DEFAULT_PASSWORD = 'password123'.freeze + FINAL_FINISH_ERROR = 'Moving Team one stage down failed' + + attr_reader :client + + def initialize(base_url:, email: nil, password: nil, username: nil) + @client = ApiClient.new(base_url: base_url) + @email = email + @password = password + @username = username + end + + def run_group_stage_main_usecase(stop_at: :final_finish_attempted) + session = register_session! + tournament = create_tournament!( + create_group_stage_payload(group_count: 4, teams_per_group: 4, playoff_teams_amount: 8) + ) + + checkpoints = { + created: summarize_tournament(tournament) + } + + timer_response = patch!( + "/tournaments/#{tournament.fetch(:id)}/set_timer_end", + body: { timer_end_seconds: 600 } + ) + checkpoints[:timer_set] = { timer_end: timer_response.fetch(:timer_end) } + return result(session: session, tournament: tournament, checkpoints: checkpoints, stop_at: stop_at) if stop_at == :timer_set + + upcoming_matches = get!( + "/tournaments/#{tournament.fetch(:id)}/matches", + params: { state: 'upcoming' } + ) + checkpoints[:upcoming_group_matches] = { match_ids: upcoming_matches.map { |match| match.fetch(:id) } } + return result(session: session, tournament: tournament, checkpoints: checkpoints, stop_at: stop_at) if stop_at == :upcoming_group_matches + + finish_group_stage_matches!(tournament) + tournament = fetch_tournament!(tournament.fetch(:id)) + checkpoints[:groups_finished] = summarize_tournament(tournament) + return result(session: session, tournament: tournament, checkpoints: checkpoints, stop_at: stop_at) if stop_at == :groups_finished + + statistics = get!("/tournaments/#{tournament.fetch(:id)}/statistics") + checkpoints[:statistics] = { + group_scores_count: statistics.fetch(:group_scores).count, + most_dominant_score_id: statistics.fetch(:most_dominant_score).fetch(:id), + least_dominant_score_id: statistics.fetch(:least_dominant_score).fetch(:id) + } + + finish_stage!(group_stage_of!(tournament).fetch(:id)) + tournament = fetch_tournament!(tournament.fetch(:id)) + checkpoints[:playoffs_created] = summarize_tournament(tournament) + return result(session: session, tournament: tournament, checkpoints: checkpoints, stop_at: stop_at) if stop_at == :playoffs_created + + run_playoffs_until_before_final!(tournament.fetch(:id)) + tournament = fetch_tournament!(tournament.fetch(:id)) + checkpoints[:before_final_finish_attempt] = summarize_tournament(tournament) + return result(session: session, tournament: tournament, checkpoints: checkpoints, stop_at: stop_at) if stop_at == :before_final_finish_attempt + + final_match = final_match_of!(tournament) + finish_attempt = play_match!( + final_match, + winner_name: preferred_playoff_winner_name(final_match), + expected_finish_status: 422, + expected_finish_error: FINAL_FINISH_ERROR + ) + tournament = fetch_tournament!(tournament.fetch(:id)) + checkpoints[:final_finish_attempted] = { + finish_attempt: finish_attempt, + tournament: summarize_tournament(tournament) + } + + result(session: session, tournament: tournament, checkpoints: checkpoints, stop_at: stop_at) + end + + def run_group_stage_intermediate_round(stop_at: :final_finish_attempted) + session = register_session! + tournament = create_tournament!( + create_group_stage_payload(group_count: 3, teams_per_group: 4, playoff_teams_amount: 4) + ) + + checkpoints = { + created: summarize_tournament(tournament) + } + return result(session: session, tournament: tournament, checkpoints: checkpoints, stop_at: stop_at) if stop_at == :created + + finish_group_stage_matches!(tournament) + finish_stage!(group_stage_of!(fetch_tournament!(tournament.fetch(:id))).fetch(:id)) + tournament = fetch_tournament!(tournament.fetch(:id)) + checkpoints[:playoffs_created] = summarize_tournament(tournament) + return result(session: session, tournament: tournament, checkpoints: checkpoints, stop_at: stop_at) if stop_at == :playoffs_created + + run_playoffs_until_before_final!(tournament.fetch(:id)) + tournament = fetch_tournament!(tournament.fetch(:id)) + checkpoints[:before_final_finish_attempt] = summarize_tournament(tournament) + return result(session: session, tournament: tournament, checkpoints: checkpoints, stop_at: stop_at) if stop_at == :before_final_finish_attempt + + final_match = final_match_of!(tournament) + finish_attempt = play_match!( + final_match, + winner_name: preferred_playoff_winner_name(final_match), + expected_finish_status: 422, + expected_finish_error: FINAL_FINISH_ERROR + ) + tournament = fetch_tournament!(tournament.fetch(:id)) + checkpoints[:final_finish_attempted] = { + finish_attempt: finish_attempt, + tournament: summarize_tournament(tournament) + } + + result(session: session, tournament: tournament, checkpoints: checkpoints, stop_at: stop_at) + end + + def run_playoff_only(team_count:, stop_at: :completed) + session = register_session! + tournament = create_tournament!(create_playoff_only_payload(team_count: team_count)) + checkpoints = { + created: summarize_tournament(tournament) + } + return result(session: session, tournament: tournament, checkpoints: checkpoints, stop_at: stop_at) if stop_at == :created + + run_playoffs_until_complete!(tournament.fetch(:id)) + tournament = fetch_tournament!(tournament.fetch(:id)) + checkpoints[:completed] = summarize_tournament(tournament) + + result(session: session, tournament: tournament, checkpoints: checkpoints, stop_at: stop_at) + end + + def wait_for_healthcheck!(timeout_seconds: 30) + started_at = Time.now + loop do + begin + response = client.get('/healthz') + return response.fetch(:json) if response.fetch(:status) == 200 + rescue Errno::ECONNREFUSED, Errno::EHOSTUNREACH + nil + end + + raise "healthcheck did not become ready within #{timeout_seconds}s" if Time.now - started_at > timeout_seconds + + sleep 0.5 + end + end + + private + + def register_session! + return login_with_existing_credentials! if @email && @password + + unique = SecureRandom.hex(6) + email = "e2e-#{unique}@example.com" + username = "e2e-#{unique}" + response = client.register!(email: email, password: DEFAULT_PASSWORD, username: username) + return { email: email, username: username } if client.authenticated? + + login_response = client.login!(email: email, password: DEFAULT_PASSWORD) + return { email: email, username: username } if login_response.fetch(:status) == 200 && client.authenticated? + + raise ApiError.new( + 'authentication bootstrap failed; provide a confirmed E2E user via TURNIERE_E2E_EMAIL and TURNIERE_E2E_PASSWORD', + status: login_response.fetch(:status), + body: login_response.fetch(:json) + ) + end + + def create_tournament!(payload) + response = client.post('/tournaments', body: payload) + expect_status!(response, 201, 'create tournament') + fetch_tournament!(response.fetch(:json).fetch(:id)) + end + + def fetch_tournament!(tournament_id) + response = client.get("/tournaments/#{tournament_id}") + expect_status!(response, 200, "fetch tournament #{tournament_id}") + response.fetch(:json) + end + + def get!(path, params: nil) + response = client.get(path, params: params) + expect_status!(response, 200, "get #{path}") + response.fetch(:json) + end + + def patch!(path, body:) + response = client.patch(path, body: body) + expect_status!(response, 200, "patch #{path}") + response.fetch(:json) + end + + def finish_group_stage_matches!(tournament) + group_stage_of!(tournament).fetch(:groups).sort_by { |group| group.fetch(:number) }.each do |group| + group.fetch(:matches).sort_by { |match| match.fetch(:position) }.each do |match| + play_match!(match, winner_name: preferred_group_winner_name(match)) + end + end + end + + def run_playoffs_until_before_final!(tournament_id) + loop do + tournament = fetch_tournament!(tournament_id) + playable_matches = playoff_matches(tournament).reject { |match| match.fetch(:stage_level) == 0 } + .select { |match| match.fetch(:state) == 'not_started' } + .sort_by { |match| [-match.fetch(:stage_level), match.fetch(:position)] } + + break if playable_matches.empty? + + playable_matches.each do |match| + play_match!(match, winner_name: preferred_playoff_winner_name(match)) + end + end + end + + def run_playoffs_until_complete!(tournament_id) + loop do + tournament = fetch_tournament!(tournament_id) + playable_matches = playoff_matches(tournament).select { |match| match.fetch(:state) == 'not_started' } + .sort_by { |match| [-match.fetch(:stage_level), match.fetch(:position)] } + break if playable_matches.empty? + + playable_matches.each do |match| + play_match!(match, winner_name: preferred_playoff_winner_name(match)) + end + end + end + + def play_match!(match, winner_name:, expected_finish_status: 200, expected_finish_error: nil) + started = client.patch("/matches/#{match.fetch(:id)}", body: { state: 'in_progress' }) + expect_status!(started, 200, "start match #{match.fetch(:id)}") + + refreshed_match = fetch_match!(match.fetch(:id)) + refreshed_match.fetch(:match_scores).sort_by { |score| score.fetch(:id) }.each do |score| + points = score.dig(:team, :name) == winner_name ? 10 : 1 + updated_score = client.patch("/match_scores/#{score.fetch(:id)}", body: { points: points }) + expect_status!(updated_score, 200, "update match_score #{score.fetch(:id)}") + end + + finished = client.patch("/matches/#{match.fetch(:id)}", body: { state: 'finished' }) + expect_status!(finished, expected_finish_status, "finish match #{match.fetch(:id)}") + if expected_finish_error + actual_error = finished.fetch(:json).fetch(:error) + raise "expected finish error #{expected_finish_error.inspect}, got #{actual_error.inspect}" unless actual_error == expected_finish_error + end + + { + match_id: match.fetch(:id), + status: finished.fetch(:status), + body: finished.fetch(:json) + } + end + + def fetch_match!(match_id) + response = client.get("/matches/#{match_id}") + expect_status!(response, 200, "fetch match #{match_id}") + response.fetch(:json) + end + + def finish_stage!(stage_id) + response = client.patch("/stages/#{stage_id}", body: { state: 'finished' }) + expect_status!(response, 200, "finish stage #{stage_id}") + response.fetch(:json) + end + + def group_stage_of!(tournament) + tournament.fetch(:stages).find { |stage| stage.fetch(:level) == -1 } || raise('group stage not found') + end + + def final_match_of!(tournament) + final_stage = tournament.fetch(:stages).find { |stage| stage.fetch(:level) == 0 } || raise('final stage not found') + attach_stage_level(final_stage).fetch(:matches).first + end + + def playoff_matches(tournament) + tournament.fetch(:stages) + .reject { |stage| stage.fetch(:level) == -1 } + .flat_map { |stage| attach_stage_level(stage).fetch(:matches) } + end + + def attach_stage_level(stage) + stage.merge(matches: stage.fetch(:matches).map { |match| match.merge(stage_level: stage.fetch(:level)) }) + end + + def preferred_group_winner_name(match) + match.fetch(:match_scores) + .map { |score| score.dig(:team, :name) } + .min_by { |name| [seed_identifier_for(name), name] } + end + + def preferred_playoff_winner_name(match) + match.fetch(:match_scores) + .map { |score| score.dig(:team, :name) } + .min_by { |name| [seed_identifier_for(name), group_identifier_for(name), name] } + end + + def group_identifier_for(team_name) + team_name[/G(\d+)/, 1].to_i + end + + def seed_identifier_for(team_name) + team_name[/S(\d+)/, 1].to_i + end + + def create_group_stage_payload(group_count:, teams_per_group:, playoff_teams_amount:) + teams = group_count.times.flat_map do |group_index| + teams_per_group.times.map do |seed_index| + { + name: "G#{group_index + 1}-S#{seed_index + 1}", + group: group_index + } + end + end + + { + name: "HTTP E2E Group Stage #{group_count}x#{teams_per_group} #{SecureRandom.hex(3)}", + description: 'HTTP lifecycle E2E tournament', + public: false, + group_stage: true, + playoff_teams_amount: playoff_teams_amount, + teams: teams + } + end + + def create_playoff_only_payload(team_count:) + { + name: "HTTP E2E Playoff Only #{team_count} #{SecureRandom.hex(3)}", + description: 'HTTP lifecycle E2E tournament', + public: false, + teams: team_count.times.map { |index| { name: "G0-S#{index + 1}" } } + } + end + + def summarize_tournament(tournament) + { + id: tournament.fetch(:id), + code: tournament.fetch(:code), + 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), + stage_summaries: tournament.fetch(:stages).map do |stage| + { + id: stage.fetch(:id), + level: stage.fetch(:level), + state: stage.fetch(:state), + group_count: stage.fetch(:groups, []).count, + match_states: stage.fetch(:matches, []).map { |match| match.fetch(:state) }, + groups: stage.fetch(:groups, []).map do |group| + { + id: group.fetch(:id), + number: group.fetch(:number), + match_states: group.fetch(:matches).map { |match| match.fetch(:state) } + } + end + } + end + } + end + + def result(session:, tournament:, checkpoints:, stop_at:) + { + session: session, + stop_at: stop_at.to_s, + tournament: summarize_tournament(tournament), + checkpoints: checkpoints + } + end + + def expect_status!(response, expected_status, action) + return if response.fetch(:status) == expected_status + + raise ApiError.new( + "#{action} failed with #{response.fetch(:status)}", + status: response.fetch(:status), + body: response.fetch(:json) + ) + end + end +end + def login_with_existing_credentials! + login_response = client.login!(email: @email, password: @password) + expect_status!(login_response, 200, "login #{@email}") + raise ApiError.new('login succeeded but no auth headers were returned', status: 200, body: login_response.fetch(:json)) unless client.authenticated? + + { email: @email, username: @username || @email } + end diff --git a/script/e2e_scenarios.rb b/script/e2e_scenarios.rb new file mode 100644 index 0000000..a5ce189 --- /dev/null +++ b/script/e2e_scenarios.rb @@ -0,0 +1,47 @@ +#!/usr/bin/env ruby +# frozen_string_literal: true + +require 'json' +require 'optparse' +require_relative '../e2e/lib/scenario_runner' + +options = { + base_url: ENV.fetch('TURNIERE_E2E_BASE_URL', 'http://127.0.0.1:3000'), + stop_at: nil, + team_count: 8, + email: ENV['TURNIERE_E2E_EMAIL'], + password: ENV['TURNIERE_E2E_PASSWORD'], + username: ENV['TURNIERE_E2E_USERNAME'] +} + +OptionParser.new do |parser| + parser.banner = 'Usage: script/e2e_scenarios.rb SCENARIO [options]' + + parser.on('--base-url URL', 'API base URL') { |value| options[:base_url] = value } + parser.on('--stop-at CHECKPOINT', 'Stop scenario at a named checkpoint') { |value| options[:stop_at] = value.to_sym } + parser.on('--team-count COUNT', Integer, 'Team count for playoff_only scenario') { |value| options[:team_count] = value } + parser.on('--email EMAIL', 'Confirmed E2E user email') { |value| options[:email] = value } + parser.on('--password PASSWORD', 'Confirmed E2E user password') { |value| options[:password] = value } + parser.on('--username USERNAME', 'Confirmed E2E user username') { |value| options[:username] = value } +end.parse! + +scenario = ARGV.shift or abort('Missing scenario name') +runner = TurniereE2E::ScenarioRunner.new( + base_url: options[:base_url], + email: options[:email], + password: options[:password], + username: options[:username] +) + +result = case scenario + when 'group_stage_main_usecase' + runner.run_group_stage_main_usecase(stop_at: options[:stop_at] || :final_finish_attempted) + when 'group_stage_intermediate_round' + runner.run_group_stage_intermediate_round(stop_at: options[:stop_at] || :final_finish_attempted) + when 'playoff_only' + runner.run_playoff_only(team_count: options[:team_count], stop_at: options[:stop_at] || :completed) + else + abort("Unknown scenario #{scenario.inspect}") + end + +puts JSON.pretty_generate(result) diff --git a/script/wait_for_http.rb b/script/wait_for_http.rb new file mode 100644 index 0000000..1513de2 --- /dev/null +++ b/script/wait_for_http.rb @@ -0,0 +1,11 @@ +#!/usr/bin/env ruby +# frozen_string_literal: true + +require_relative '../e2e/lib/scenario_runner' + +url = ARGV[0] || ENV.fetch('TURNIERE_E2E_BASE_URL', 'http://127.0.0.1:3000') +timeout = (ARGV[1] || 30).to_i + +runner = TurniereE2E::ScenarioRunner.new(base_url: url) +runner.wait_for_healthcheck!(timeout_seconds: timeout) +puts "ready: #{url}" diff --git a/spec/e2e/http/tournament_lifecycle_spec.rb b/spec/e2e/http/tournament_lifecycle_spec.rb new file mode 100644 index 0000000..af96219 --- /dev/null +++ b/spec/e2e/http/tournament_lifecycle_spec.rb @@ -0,0 +1,64 @@ +# frozen_string_literal: true + +require 'spec_helper' +require_relative '../../../e2e/lib/scenario_runner' + +RSpec.describe 'Tournament lifecycle HTTP E2E' do + let(:base_url) { ENV.fetch('TURNIERE_E2E_BASE_URL', 'http://127.0.0.1:3000') } + let(:runner) do + TurniereE2E::ScenarioRunner.new( + base_url: base_url, + email: ENV.fetch('TURNIERE_E2E_EMAIL'), + password: ENV.fetch('TURNIERE_E2E_PASSWORD'), + username: ENV['TURNIERE_E2E_USERNAME'] + ) + end + + it 'drives the main 4x4 group-stage use case over HTTP and preserves the current final-finish behavior' do + result = runner.run_group_stage_main_usecase + + expect(result.dig(:checkpoints, :created, :playoff_teams_amount)).to eq(8) + expect(result.dig(:checkpoints, :created, :instant_finalists_amount)).to eq(8) + expect(result.dig(:checkpoints, :created, :intermediate_round_participants_amount)).to eq(0) + expect(result.dig(:checkpoints, :upcoming_group_matches, :match_ids).count).to eq(4) + expect(result.dig(:checkpoints, :statistics, :group_scores_count)).to eq(16) + + playoffs_created = result.dig(:checkpoints, :playoffs_created, :stage_summaries) + playoff_entry_stage = playoffs_created.select { |stage| stage[:level] != -1 }.max_by { |stage| stage[:level] } + expect(playoff_entry_stage[:match_states].count).to eq(4) + + final_attempt = result.dig(:checkpoints, :final_finish_attempted, :finish_attempt) + expect(final_attempt[:status]).to eq(422) + expect(final_attempt.dig(:body, :error)).to eq('Moving Team one stage down failed') + end + + it 'drives the 3-group intermediate-round path over HTTP and preserves the current advancing behavior' do + result = runner.run_group_stage_intermediate_round + + created = result.fetch(:checkpoints).fetch(:created) + expect(created[:playoff_teams_amount]).to eq(4) + expect(created[:instant_finalists_amount]).to eq(3) + expect(created[:intermediate_round_participants_amount]).to eq(2) + + playoff_entry_stage = result.dig(:checkpoints, :playoffs_created, :stage_summaries) + .select { |stage| stage[:level] != -1 } + .max_by { |stage| stage[:level] } + expect(playoff_entry_stage[:state]).to eq('intermediate_stage') + expect(playoff_entry_stage[:match_states].count('single_team')).to eq(3) + expect(playoff_entry_stage[:match_states].count('not_started')).to eq(1) + + final_attempt = result.dig(:checkpoints, :final_finish_attempted, :finish_attempt) + expect(final_attempt[:status]).to eq(422) + expect(final_attempt.dig(:body, :error)).to eq('Moving Team one stage down failed') + end + + [2, 3, 5, 8].each do |team_count| + it "drives a #{team_count}-team playoff-only tournament over HTTP to completion" do + result = runner.run_playoff_only(team_count: team_count) + + completed_stages = result.dig(:checkpoints, :completed, :stage_summaries) + final_stage = completed_stages.find { |stage| stage[:level] == 0 } + expect(final_stage[:match_states]).to eq(['finished']) + end + end +end diff --git a/spec/e2e_known_behaviors.md b/spec/e2e_known_behaviors.md index d1e014e..fccc3d4 100644 --- a/spec/e2e_known_behaviors.md +++ b/spec/e2e_known_behaviors.md @@ -2,9 +2,27 @@ This file records non-obvious backend behaviors that the E2E suite intentionally preserves. +## HTTP E2E auth bootstrap + +The lifecycle scenarios themselves run only through the HTTP API. + +One bootstrap exception currently exists: + +- the backend requires email confirmation before a newly registered user can sign in +- there is no public API to complete that confirmation loop without accessing the email channel +- because of that, HTTP E2E runs need a pre-confirmed user + +The reusable scenario script supports this through: + +- `TURNIERE_E2E_EMAIL` +- `TURNIERE_E2E_PASSWORD` +- optional `TURNIERE_E2E_USERNAME` + +Frontend tests can use the same script against a live backend as long as those credentials point to a confirmed account on that backend. + ## Group-stage tournaments cannot finish the playoff final through the normal match endpoint -Observed and covered by `spec/requests/e2e/tournament_lifecycle_spec.rb`: +Observed and covered by `spec/e2e/http/tournament_lifecycle_spec.rb` and `script/e2e_scenarios.rb`: - in tournaments that started with a group stage, the playoff final can be started and scored - but `PATCH /matches/:id` with `state=finished` on that final returns: @@ -16,7 +34,7 @@ This appears to happen because playoff stages count down to level `0`, while the ## Group stage to playoff conversion with 3 groups and `playoff_teams_amount = 4` -Observed and covered by `spec/requests/e2e/tournament_lifecycle_spec.rb`: +Observed and covered by `spec/e2e/http/tournament_lifecycle_spec.rb` and `script/e2e_scenarios.rb`: - `instant_finalists_amount` becomes `3` - `intermediate_round_participants_amount` becomes `2` diff --git a/spec/requests/e2e/tournament_lifecycle_spec.rb b/spec/requests/e2e/tournament_lifecycle_spec.rb deleted file mode 100644 index 3eb1406..0000000 --- a/spec/requests/e2e/tournament_lifecycle_spec.rb +++ /dev/null @@ -1,298 +0,0 @@ -# frozen_string_literal: true - -require 'rails_helper' - -RSpec.describe 'Tournament lifecycle E2E', type: :request do - def parsed_response - JSON.parse(response.body, symbolize_names: true) - end - - def auth_headers_for(user) - user.create_new_auth_token.merge( - 'ACCEPT' => 'application/json' - ) - end - - def create_tournament!(user:, params:) - post '/tournaments', params:, headers: auth_headers_for(user), as: :json - - expect(response).to have_http_status(:created) - Tournament.find(parsed_response[:id]) - end - - def update_timer_by_seconds!(tournament, user, seconds) - patch "/tournaments/#{tournament.id}/set_timer_end", - params: { timer_end_seconds: seconds }, - headers: auth_headers_for(user), - as: :json - - expect(response).to have_http_status(:ok) - parsed_response - end - - def group_identifier_for(team) - team.name[/G(\d+)/, 1].to_i - end - - def seed_identifier_for(team) - team.name[/S(\d+)/, 1].to_i - end - - def playoff_rank_for(team) - [seed_identifier_for(team), group_identifier_for(team), team.name] - end - - def preferred_winner_for(match) - match.teams.min_by { |team| playoff_rank_for(team) } - end - - def play_match!(match, user:, winner: preferred_winner_for(match), winning_points: 10, losing_points: 1, - expected_finish_status: 200, expected_finish_error: nil) - patch "/matches/#{match.id}", - params: { state: 'in_progress' }, - headers: auth_headers_for(user), - as: :json - expect(response.status).to eq(200), "starting match #{match.id} failed: #{response.body}" - - ordered_scores = match.reload.match_scores.sort_by(&:id) - ordered_scores.each do |match_score| - points = match_score.team == winner ? winning_points : losing_points - patch "/match_scores/#{match_score.id}", - params: { points: }, - headers: auth_headers_for(user), - as: :json - expect(response.status).to eq(200), "updating match_score #{match_score.id} failed: #{response.body}" - end - - patch "/matches/#{match.id}", - params: { state: 'finished' }, - headers: auth_headers_for(user), - as: :json - expect(response.status).to eq( - expected_finish_status - ), "finishing match #{match.id} with teams #{match.reload.teams.map(&:name)} failed: #{response.body}" - return if expected_finish_error.nil? - - expect(parsed_response[:error]).to eq(expected_finish_error) - end - - def finish_group_stage_matches!(group_stage, user:) - group_stage.groups.order(:number).each do |group| - group.matches.sort_by(&:position).each do |match| - winner = match.teams.min_by { |team| [seed_identifier_for(team), team.name] } - play_match!(match, user:, winner:) - end - end - end - - def finish_stage!(stage, user:) - patch "/stages/#{stage.id}", - params: { state: 'finished' }, - headers: auth_headers_for(user), - as: :json - - expect(response).to have_http_status(:ok) - end - - def run_playoff_to_completion!(tournament, user:, include_final: true) - loop do - excluded_levels = [-1] - excluded_levels << 0 unless include_final - - playable_matches = tournament.reload.stages - .reject { |stage| excluded_levels.include?(stage.level) } - .flat_map(&:matches) - .select(&:not_started?) - .sort_by { |match| [-match.stage.level, match.position] } - - break if playable_matches.empty? - - playable_matches.each do |match| - play_match!(match, user:) - end - end - - tournament.reload - end - - def create_group_stage_payload(group_count:, teams_per_group:, playoff_teams_amount:) - teams = group_count.times.flat_map do |group_index| - teams_per_group.times.map do |seed_index| - { - name: "G#{group_index + 1}-S#{seed_index + 1}", - group: group_index - } - end - end - - { - name: "Group stage #{group_count}x#{teams_per_group}", - description: 'Lifecycle E2E tournament', - public: false, - group_stage: true, - playoff_teams_amount:, - teams: - } - end - - def create_playoff_only_payload(team_count:) - { - name: "Playoff only #{team_count}", - description: 'Lifecycle E2E tournament', - public: false, - teams: team_count.times.map { |index| { name: "G0-S#{index + 1}" } } - } - end - - let(:user) { create(:user) } - - describe 'group stage lifecycle with four teams per group' do - it 'creates a 16-team tournament, finishes groups, generates playoffs, and reaches a champion' do - tournament = create_tournament!( - user:, - params: create_group_stage_payload(group_count: 4, teams_per_group: 4, playoff_teams_amount: 8) - ) - - expect(tournament.playoff_teams_amount).to eq(8) - expect(tournament.instant_finalists_amount).to eq(8) - expect(tournament.intermediate_round_participants_amount).to eq(0) - - group_stage = tournament.stages.find_by(level: -1) - expect(group_stage).to be_present - expect(group_stage.groups.count).to eq(4) - expect(group_stage.groups.map(&:number)).to match_array([1, 2, 3, 4]) - expect(group_stage.groups.map { |group| group.matches.count }).to all(eq(6)) - - get "/tournaments/#{tournament.id}/matches", - params: { state: 'upcoming' }, - headers: { 'ACCEPT' => 'application/json' } - expect(response).to have_http_status(:ok) - expect(parsed_response.count).to eq(4) - expect(parsed_response.map { |match| match[:position] }).to all(eq(0)) - - timer_response = update_timer_by_seconds!(tournament, user, 600) - expect(Time.zone.parse(timer_response[:timer_end])).to be > Time.zone.now - - get "/tournaments/#{tournament.id}/timer_end", headers: { 'ACCEPT' => 'application/json' } - expect(response).to have_http_status(:ok) - expect(Time.zone.parse(parsed_response[:timer_end])).to be > Time.zone.now - - finish_group_stage_matches!(group_stage, user:) - - get "/tournaments/#{tournament.id}/statistics", headers: { 'ACCEPT' => 'application/json' } - expect(response).to have_http_status(:ok) - expect(parsed_response[:group_scores].count).to eq(16) - expect(parsed_response[:most_dominant_score]).to be_present - expect(parsed_response[:least_dominant_score]).to be_present - - advancing_teams_before_finish = tournament.reload.teams_advancing_from_group_stage - expect(advancing_teams_before_finish.map(&:name)).to eq( - %w[G1-S1 G3-S2 G2-S1 G4-S2 G3-S1 G1-S2 G4-S1 G2-S2] - ) - - finish_stage!(group_stage, user:) - - tournament.reload - playoff_entry_stage = tournament.stages.where.not(level: -1).max_by(&:level) - expect(playoff_entry_stage.matches.count).to eq(4) - expect(playoff_entry_stage.matches.sort_by(&:position).map { |match| match.teams.map(&:name) }).to eq( - [ - %w[G1-S1 G3-S2], - %w[G2-S1 G4-S2], - %w[G3-S1 G1-S2], - %w[G4-S1 G2-S2] - ] - ) - - run_playoff_to_completion!(tournament, user:, include_final: false) - - final_stage = tournament.stages.find_by(level: 0) - final_match = final_stage.matches.first - expect(final_match).to be_not_started - expect(final_match.teams.map(&:name)).to match_array(%w[G1-S1 G3-S1]) - - play_match!( - final_match, - user:, - expected_finish_status: 422, - expected_finish_error: 'Moving Team one stage down failed' - ) - - final_match.reload - expect(final_match).to be_in_progress - expect(final_match.current_leading_team.name).to eq('G1-S1') - end - - it 'preserves the current intermediate-round behavior for three groups advancing into a four-team playoff' do - tournament = create_tournament!( - user:, - params: create_group_stage_payload(group_count: 3, teams_per_group: 4, playoff_teams_amount: 4) - ) - - expect(tournament.playoff_teams_amount).to eq(4) - expect(tournament.instant_finalists_amount).to eq(3) - expect(tournament.intermediate_round_participants_amount).to eq(2) - - group_stage = tournament.stages.find_by(level: -1) - finish_group_stage_matches!(group_stage, user:) - finish_stage!(group_stage, user:) - - tournament.reload - playoff_entry_stage = tournament.stages.where.not(level: -1).max_by(&:level) - expect(playoff_entry_stage).to be_intermediate_stage - expect(playoff_entry_stage.matches.count).to eq(4) - expect(playoff_entry_stage.matches.count(&:single_team?)).to eq(3) - expect(playoff_entry_stage.matches.count(&:not_started?)).to eq(1) - - advancing_team_names = playoff_entry_stage.matches.flat_map { |match| match.teams.map(&:name) } - expect(advancing_team_names.uniq.count).to eq(5) - expect(advancing_team_names).to include('G1-S1', 'G2-S1', 'G3-S1') - - run_playoff_to_completion!(tournament, user:, include_final: false) - - final_match = tournament.stages.find_by(level: 0).matches.first - expect(final_match).to be_not_started - expect(final_match.teams.map(&:name)).to match_array(%w[G3-S1 G1-S1]) - - play_match!( - final_match, - user:, - expected_finish_status: 422, - expected_finish_error: 'Moving Team one stage down failed' - ) - - final_match.reload - expect(final_match).to be_in_progress - expect(final_match.current_leading_team.name).to eq('G1-S1') - end - end - - describe 'playoff-only lifecycle across different team counts' do - [2, 3, 5, 8].each do |team_count| - it "plays a #{team_count}-team playoff tournament through to the end" do - tournament = create_tournament!( - user:, - params: create_playoff_only_payload(team_count:) - ) - - opening_stage = tournament.stages.max_by(&:level) - expect(opening_stage).to be_present - expect(opening_stage.matches).not_to be_empty - - if Utils.po2?(team_count) - expect(opening_stage).to be_playoff_stage - else - expect(opening_stage).to be_intermediate_stage - expect(opening_stage.matches.count(&:single_team?)).to be > 0 - end - - run_playoff_to_completion!(tournament, user:) - - tournament.reload - final_match = tournament.stages.find_by(level: 0).matches.first - expect(final_match).to be_finished - expect(final_match.winner.name).to eq('G0-S1') - end - end - end -end From 35d81b53fd559227edb3109ae20e8588fe52fd63 Mon Sep 17 00:00:00 2001 From: Malaber Date: Sat, 11 Apr 2026 13:44:56 +0200 Subject: [PATCH 03/18] Fix GitLab CI YAML quoting --- .gitlab-ci.yml | 18 +++++++++--------- 1 file changed, 9 insertions(+), 9 deletions(-) diff --git a/.gitlab-ci.yml b/.gitlab-ci.yml index 356e859..cde1c26 100644 --- a/.gitlab-ci.yml +++ b/.gitlab-ci.yml @@ -35,13 +35,13 @@ rails e2e spec: when: never - when: always script: - - cd /app - - bundle exec rails db:migrate - - bundle exec rails runner -e test "user = User.find_or_initialize_by(email: 'e2e@example.com'); user.username = 'e2e-user'; user.password = 'password123'; user.password_confirmation = 'password123'; user.confirmed_at = Time.current; user.uid = user.email; user.provider = 'email'; user.save!" - - bundle exec rails s -e test -p 3000 -b 0.0.0.0 > /tmp/turniere-e2e-server.log 2>&1 & - - SERVER_PID=$! - - ruby script/wait_for_http.rb http://127.0.0.1:3000 60 - - TURNIERE_E2E_BASE_URL=http://127.0.0.1:3000 TURNIERE_E2E_EMAIL=e2e@example.com TURNIERE_E2E_PASSWORD=password123 TURNIERE_E2E_USERNAME=e2e-user bundle exec rspec spec/e2e/http - - kill $SERVER_PID + - 'cd /app' + - 'bundle exec rails db:migrate' + - 'bundle exec rails runner -e test "user = User.find_or_initialize_by(email: ''e2e@example.com''); user.username = ''e2e-user''; user.password = ''password123''; user.password_confirmation = ''password123''; user.confirmed_at = Time.current; user.uid = user.email; user.provider = ''email''; user.save!"' + - 'bundle exec rails s -e test -p 3000 -b 0.0.0.0 > /tmp/turniere-e2e-server.log 2>&1 &' + - 'SERVER_PID=$!' + - 'ruby script/wait_for_http.rb http://127.0.0.1:3000 60' + - 'TURNIERE_E2E_BASE_URL=http://127.0.0.1:3000 TURNIERE_E2E_EMAIL=e2e@example.com TURNIERE_E2E_PASSWORD=password123 TURNIERE_E2E_USERNAME=e2e-user bundle exec rspec spec/e2e/http' + - 'kill $SERVER_PID' after_script: - - test -f /tmp/turniere-e2e-server.log && tail -n 200 /tmp/turniere-e2e-server.log || true + - 'test -f /tmp/turniere-e2e-server.log && tail -n 200 /tmp/turniere-e2e-server.log || true' From eca1ba562203cd020153fbf4d23283c40ac5ace4 Mon Sep 17 00:00:00 2001 From: Malaber Date: Sat, 11 Apr 2026 14:15:18 +0200 Subject: [PATCH 04/18] Skip HTTP E2E outside dedicated env --- spec/e2e/http/tournament_lifecycle_spec.rb | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/spec/e2e/http/tournament_lifecycle_spec.rb b/spec/e2e/http/tournament_lifecycle_spec.rb index af96219..a65b492 100644 --- a/spec/e2e/http/tournament_lifecycle_spec.rb +++ b/spec/e2e/http/tournament_lifecycle_spec.rb @@ -4,6 +4,13 @@ require 'spec_helper' require_relative '../../../e2e/lib/scenario_runner' RSpec.describe 'Tournament lifecycle HTTP E2E' do + required_env_vars = %w[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', 'http://127.0.0.1:3000') } let(:runner) do TurniereE2E::ScenarioRunner.new( From 986ef9e925f4ec1cb7101866afab0e5fd2e281fc Mon Sep 17 00:00:00 2001 From: Malaber Date: Sat, 11 Apr 2026 15:08:42 +0200 Subject: [PATCH 05/18] Use invoke for backend workflows and CI --- .gitlab-ci.yml | 16 +-- AGENTS.md | 70 ++++++++++++ tasks.py | 295 +++++++++++++++++++++++++++++++++++++++++++++++++ 3 files changed, 371 insertions(+), 10 deletions(-) create mode 100644 AGENTS.md create mode 100644 tasks.py diff --git a/.gitlab-ci.yml b/.gitlab-ci.yml index cde1c26..cd41860 100644 --- a/.gitlab-ci.yml +++ b/.gitlab-ci.yml @@ -18,8 +18,9 @@ rails spec: - when: always script: - cd /app - - bundle exec rails db:migrate - - bundle exec rspec + - python3 -m pip install invoke + - inv db-migrate + - inv test artifacts: paths: - specs_with_runtime.txt @@ -35,13 +36,8 @@ rails e2e spec: when: never - when: always script: - - 'cd /app' - - 'bundle exec rails db:migrate' - - 'bundle exec rails runner -e test "user = User.find_or_initialize_by(email: ''e2e@example.com''); user.username = ''e2e-user''; user.password = ''password123''; user.password_confirmation = ''password123''; user.confirmed_at = Time.current; user.uid = user.email; user.provider = ''email''; user.save!"' - - 'bundle exec rails s -e test -p 3000 -b 0.0.0.0 > /tmp/turniere-e2e-server.log 2>&1 &' - - 'SERVER_PID=$!' - - 'ruby script/wait_for_http.rb http://127.0.0.1:3000 60' - - 'TURNIERE_E2E_BASE_URL=http://127.0.0.1:3000 TURNIERE_E2E_EMAIL=e2e@example.com TURNIERE_E2E_PASSWORD=password123 TURNIERE_E2E_USERNAME=e2e-user bundle exec rspec spec/e2e/http' - - 'kill $SERVER_PID' + - cd /app + - python3 -m pip install invoke + - inv verify-http after_script: - 'test -f /tmp/turniere-e2e-server.log && tail -n 200 /tmp/turniere-e2e-server.log || true' diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 0000000..8d434a6 --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,70 @@ +# turniere-backend Agent Notes + +This repository should be operated through `invoke` tasks by default. + +For routine development, verification, Docker, and E2E work, agents should use +`inv ...` rather than ad-hoc shell commands unless there is a strong reason not to. + +## Default Workflow + +Use `tasks.py` and run commands through `inv ...` instead of ad-hoc shell commands for routine work. + +Examples: + +- `inv db-migrate` +- `inv test` +- `inv lint` +- `inv verify-http` +- `inv scenario-main-usecase` +- `inv docker-build-all` +- `inv docker-test-http-e2e` + +The GitLab CI pipeline is expected to use these tasks as well. + +This is not just a convenience preference. The task layer is the operational +contract for this repo and should stay aligned across local use, CI, and +cross-repo consumers. + +## Why + +The point of the task layer is: + +- one stable entrypoint for local development +- one stable entrypoint for CI +- one stable entrypoint for frontend and cross-repo test setup +- fewer undocumented command variants + +If a workflow matters often enough that a human or CI needs to remember it, it +should usually become an `invoke` task. + +## Maintenance Rule + +When new functionality or recurring maintenance work is added, extend `tasks.py` +so the new workflow stays easy to discover and easy to run. + +Do not leave important multi-step flows only in: + +- CI YAML +- MR descriptions +- shell history +- team memory + +Instead, add or update an `inv` task and then have CI or docs call that task. + +If a new backend capability introduces a meaningful setup, verification, fixture, +or scenario workflow, add or extend a task for it as part of the same change. + +## HTTP E2E + +The backend HTTP E2E flow is intended to be reusable outside this repo, especially by frontend tests that need realistic backend state. + +That means: + +- scenario creation should stay HTTP-driven +- reusable scenario setup should be exposed through `inv` tasks and `script/e2e_scenarios.rb` +- new commonly needed backend states should be added to the scenario/task layer, not recreated ad hoc in each consuming test suite + +## Practical Expectation + +If you touch test, verification, boot, Docker, or scenario setup workflows, +check whether `tasks.py` also needs to change. diff --git a/tasks.py b/tasks.py new file mode 100644 index 0000000..d638c88 --- /dev/null +++ b/tasks.py @@ -0,0 +1,295 @@ +from invoke import task + +import os +import signal +import subprocess +import time +import urllib.request + + +SERVER_HOST = "0.0.0.0" +SERVER_PORT = "3000" +TEST_BASE_URL = f"http://127.0.0.1:{SERVER_PORT}" + +E2E_EMAIL = "e2e@example.com" +E2E_PASSWORD = "password123" +E2E_USERNAME = "e2e-user" + +PRODUCTION_TAG = "registry.gitlab.com/turniere/turniere-backend/production/commits:local" +DEVELOPMENT_TAG = "registry.gitlab.com/turniere/turniere-backend/development/commits:local" +TEST_TAG = "registry.gitlab.com/turniere/turniere-backend/test/commits:local" + + +def _env(**overrides): + env = os.environ.copy() + env.update({key: str(value) for key, value in overrides.items()}) + return env + + +def _wait_for_http(base_url=TEST_BASE_URL, timeout=60): + deadline = time.time() + timeout + healthz_url = f"{base_url}/healthz" + + while time.time() < deadline: + try: + with urllib.request.urlopen(healthz_url) as response: + if response.status == 200: + return + except Exception: + time.sleep(0.5) + + raise RuntimeError(f"healthcheck did not become ready within {timeout}s: {healthz_url}") + + +def _start_test_server(): + log_handle = open("/tmp/turniere-e2e-server.log", "w") + process = subprocess.Popen( + f"bundle exec rails s -e test -b {SERVER_HOST} -p {SERVER_PORT}", + shell=True, + stdout=log_handle, + stderr=subprocess.STDOUT, + preexec_fn=os.setsid, + ) + return process, log_handle + + +def _stop_process(process, log_handle): + try: + os.killpg(os.getpgid(process.pid), signal.SIGTERM) + except ProcessLookupError: + pass + finally: + log_handle.close() + + +@task +def setup(c): + """Install gem dependencies locally.""" + c.run("bundle install", pty=True) + + +@task(name="db-migrate") +def db_migrate(c): + """Run database migrations locally.""" + c.run("bundle exec rails db:migrate", pty=True) + + +@task +def server(c, host=SERVER_HOST, port=SERVER_PORT): + """Start the backend locally in development.""" + c.run(f"bundle exec rails s -b {host} -p {port}", pty=True) + + +@task(name="server-test") +def server_test(c, host=SERVER_HOST, port=SERVER_PORT): + """Start the backend locally in test mode.""" + c.run(f"bundle exec rails s -e test -b {host} -p {port}", pty=True) + + +@task +def test(c): + """Run the normal RSpec suite.""" + c.run("bundle exec rspec", pty=True) + + +@task +def lint(c): + """Run RuboCop.""" + c.run("bundle exec rubocop", pty=True) + + +@task(name="bootstrap-e2e-user") +def bootstrap_e2e_user(c, email=E2E_EMAIL, password=E2E_PASSWORD, username=E2E_USERNAME): + """Create or update the confirmed HTTP E2E user in test env.""" + command = ( + "bundle exec rails runner -e test " + f"\"user = User.find_or_initialize_by(email: '{email}'); " + f"user.username = '{username}'; " + f"user.password = '{password}'; " + f"user.password_confirmation = '{password}'; " + "user.confirmed_at = Time.current; " + "user.uid = user.email; " + "user.provider = 'email'; " + "user.save!\"" + ) + c.run(command, pty=True) + + +@task(name="test-http-e2e") +def test_http_e2e( + c, + base_url=TEST_BASE_URL, + email=E2E_EMAIL, + password=E2E_PASSWORD, + username=E2E_USERNAME, +): + """Run HTTP E2E specs against an already running backend.""" + c.run( + "bundle exec rspec spec/e2e/http", + env=_env( + TURNIERE_E2E_BASE_URL=base_url, + TURNIERE_E2E_EMAIL=email, + TURNIERE_E2E_PASSWORD=password, + TURNIERE_E2E_USERNAME=username, + ), + pty=True, + ) + + +@task(name="verify-http") +def verify_http( + c, + base_url=TEST_BASE_URL, + email=E2E_EMAIL, + password=E2E_PASSWORD, + username=E2E_USERNAME, +): + """Boot a local test server, then run the HTTP E2E suite against it.""" + db_migrate(c) + bootstrap_e2e_user(c, email=email, password=password, username=username) + + process, log_handle = _start_test_server() + try: + _wait_for_http(base_url=base_url, timeout=60) + test_http_e2e(c, base_url=base_url, email=email, password=password, username=username) + finally: + _stop_process(process, log_handle) + + +@task +def verify(c): + """Run lint, normal specs, and HTTP E2E locally.""" + lint(c) + test(c) + verify_http(c) + + +@task(name="scenario-main-usecase") +def scenario_main_usecase( + c, + base_url=TEST_BASE_URL, + email=E2E_EMAIL, + password=E2E_PASSWORD, + username=E2E_USERNAME, + stop_at="final_finish_attempted", +): + """Materialize the main 4x4 group-stage scenario over HTTP.""" + c.run( + f"ruby script/e2e_scenarios.rb group_stage_main_usecase --stop-at {stop_at}", + env=_env( + TURNIERE_E2E_BASE_URL=base_url, + TURNIERE_E2E_EMAIL=email, + TURNIERE_E2E_PASSWORD=password, + TURNIERE_E2E_USERNAME=username, + ), + pty=True, + ) + + +@task(name="scenario-intermediate") +def scenario_intermediate( + c, + base_url=TEST_BASE_URL, + email=E2E_EMAIL, + password=E2E_PASSWORD, + username=E2E_USERNAME, + stop_at="final_finish_attempted", +): + """Materialize the 3-group intermediate-round scenario over HTTP.""" + c.run( + f"ruby script/e2e_scenarios.rb group_stage_intermediate_round --stop-at {stop_at}", + env=_env( + TURNIERE_E2E_BASE_URL=base_url, + TURNIERE_E2E_EMAIL=email, + TURNIERE_E2E_PASSWORD=password, + TURNIERE_E2E_USERNAME=username, + ), + pty=True, + ) + + +@task(name="scenario-playoff") +def scenario_playoff( + c, + team_count=8, + base_url=TEST_BASE_URL, + email=E2E_EMAIL, + password=E2E_PASSWORD, + username=E2E_USERNAME, + stop_at="completed", +): + """Materialize a playoff-only scenario over HTTP.""" + c.run( + f"ruby script/e2e_scenarios.rb playoff_only --team-count {team_count} --stop-at {stop_at}", + env=_env( + TURNIERE_E2E_BASE_URL=base_url, + TURNIERE_E2E_EMAIL=email, + TURNIERE_E2E_PASSWORD=password, + TURNIERE_E2E_USERNAME=username, + ), + pty=True, + ) + + +@task(name="docker-build-production") +def docker_build_production(c, tag=PRODUCTION_TAG): + """Build the production Docker image.""" + c.run(f"docker build -t {tag} -f docker/production/Dockerfile .", pty=True) + + +@task(name="docker-build-development") +def docker_build_development(c, tag=DEVELOPMENT_TAG): + """Build the development Docker image.""" + c.run( + f"docker build --build-arg base_commit=local -t {tag} -f docker/development/Dockerfile .", + pty=True, + ) + + +@task(name="docker-build-test") +def docker_build_test(c, tag=TEST_TAG): + """Build the test Docker image.""" + c.run(f"docker build --build-arg base_commit=local -t {tag} -f docker/test/Dockerfile .", pty=True) + + +@task(name="docker-build-all") +def docker_build_all(c): + """Build the production, development, and test Docker images.""" + docker_build_production(c) + docker_build_development(c) + docker_build_test(c) + + +@task(name="docker-test-http-e2e") +def docker_test_http_e2e( + c, + image=TEST_TAG, + email=E2E_EMAIL, + password=E2E_PASSWORD, + username=E2E_USERNAME, +): + """Run the HTTP E2E suite inside the test Docker image against a live Rails server.""" + command = ( + f"docker run --rm {image} bash -lc " + f"\"cd /app && " + "bundle exec rails db:migrate && " + "bundle exec rails runner -e test " + f"\\\"user = User.find_or_initialize_by(email: '{email}'); " + f"user.username = '{username}'; " + f"user.password = '{password}'; " + f"user.password_confirmation = '{password}'; " + "user.confirmed_at = Time.current; " + "user.uid = user.email; " + "user.provider = 'email'; " + "user.save!\\\" && " + f"bundle exec rails s -e test -p {SERVER_PORT} -b 0.0.0.0 >/tmp/turniere-e2e-server.log 2>&1 & " + "SERVER_PID=\\$! && " + f"ruby script/wait_for_http.rb {TEST_BASE_URL} 60 && " + f"TURNIERE_E2E_BASE_URL={TEST_BASE_URL} " + f"TURNIERE_E2E_EMAIL={email} " + f"TURNIERE_E2E_PASSWORD={password} " + f"TURNIERE_E2E_USERNAME={username} " + "bundle exec rspec spec/e2e/http && " + "kill \\$SERVER_PID\"" + ) + c.run(command, pty=True) From 919aeabf86d16d305aa430ac6930b9d734d2f90b Mon Sep 17 00:00:00 2001 From: Malaber Date: Sat, 11 Apr 2026 15:17:57 +0200 Subject: [PATCH 06/18] Fix python install --- .gitlab-ci.yml | 2 -- docker/production/Dockerfile | 2 +- 2 files changed, 1 insertion(+), 3 deletions(-) diff --git a/.gitlab-ci.yml b/.gitlab-ci.yml index cd41860..677601a 100644 --- a/.gitlab-ci.yml +++ b/.gitlab-ci.yml @@ -18,7 +18,6 @@ rails spec: - when: always script: - cd /app - - python3 -m pip install invoke - inv db-migrate - inv test artifacts: @@ -37,7 +36,6 @@ rails e2e spec: - when: always script: - cd /app - - python3 -m pip install invoke - inv verify-http after_script: - 'test -f /tmp/turniere-e2e-server.log && tail -n 200 /tmp/turniere-e2e-server.log || true' diff --git a/docker/production/Dockerfile b/docker/production/Dockerfile index 42a6684..6840fc3 100644 --- a/docker/production/Dockerfile +++ b/docker/production/Dockerfile @@ -1,7 +1,7 @@ ARG base_commit FROM ruby:3.1.2-slim # RUN apk add --no-cache build-base tzdata sqlite sqlite-dev postgresql-dev git && gem install tzinfo-data -RUN apt-get update -qq && apt-get install -y openssl git build-essential libpq-dev nodejs && apt-get clean && gem install tzinfo-data +RUN apt-get update -qq && apt-get install -y openssl git build-essential libpq-dev nodejs python3 python3-pip && apt-get clean && gem install tzinfo-data && python3 -m pip install --no-cache-dir invoke WORKDIR /app COPY Gemfile* /app/ RUN gem install bundler:2.3.13 && bundle config set deployment 'true' && bundle install From 0bbedd72bd19a183ec8436397929ba6c921096c0 Mon Sep 17 00:00:00 2001 From: Malaber Date: Sat, 11 Apr 2026 15:39:13 +0200 Subject: [PATCH 07/18] Add production blackbox e2e workflow --- .gitignore | 3 +- .gitlab-ci.yml | 24 ++- AGENTS.md | 3 + docker-compose.blackbox.yml | 46 +++++ tasks.py | 365 ++++++++++++++++++++++++++++++++++++ 5 files changed, 437 insertions(+), 4 deletions(-) create mode 100644 docker-compose.blackbox.yml diff --git a/.gitignore b/.gitignore index 369ab0c..81f45c4 100644 --- a/.gitignore +++ b/.gitignore @@ -34,4 +34,5 @@ coverage/** /config/credentials/beta.key -/specs_with_runtime.txt \ No newline at end of file +/specs_with_runtime.txt +.venv/ diff --git a/.gitlab-ci.yml b/.gitlab-ci.yml index 677601a..5e92881 100644 --- a/.gitlab-ci.yml +++ b/.gitlab-ci.yml @@ -28,6 +28,26 @@ rails spec: rails e2e spec: stage: test image: $CI_REGISTRY_IMAGE/test/commits:$CI_COMMIT_SHA + services: + - name: postgres:16 + alias: postgres + - name: $CI_REGISTRY_IMAGE/production/commits:$CI_COMMIT_SHA + alias: app + variables: + POSTGRES_DB: turniere_blackbox + POSTGRES_USER: turniere + POSTGRES_PASSWORD: turniere + POSTGRES_URL: postgres://turniere:turniere@postgres:5432/turniere_blackbox + POSTGRES_USERNAME: turniere + SECRET_KEY_BASE: turniere-blackbox-secret-key-base-please-change-in-real-prod + MAILGUN_API_KEY: blackbox-test-api-key + MAILGUN_DOMAIN: blackbox.example.com + RAILS_LOG_TO_STDOUT: "1" + RAILS_SERVE_STATIC_FILES: "1" + TURNIERE_E2E_BASE_URL: http://app:3000 + TURNIERE_E2E_EMAIL: e2e@example.com + TURNIERE_E2E_PASSWORD: password123 + TURNIERE_E2E_USERNAME: e2e-user rules: - if: $SKIP_TEST when: never @@ -36,6 +56,4 @@ rails e2e spec: - when: always script: - cd /app - - inv verify-http - after_script: - - 'test -f /tmp/turniere-e2e-server.log && tail -n 200 /tmp/turniere-e2e-server.log || true' + - inv blackbox-service diff --git a/AGENTS.md b/AGENTS.md index 8d434a6..146eb18 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -15,11 +15,13 @@ Examples: - `inv test` - `inv lint` - `inv verify-http` +- `inv blackbox-production` - `inv scenario-main-usecase` - `inv docker-build-all` - `inv docker-test-http-e2e` The GitLab CI pipeline is expected to use these tasks as well. +In CI, blackbox E2E should target sidecar service containers rather than Docker Compose inside the job. This is not just a convenience preference. The task layer is the operational contract for this repo and should stay aligned across local use, CI, and @@ -63,6 +65,7 @@ That means: - scenario creation should stay HTTP-driven - reusable scenario setup should be exposed through `inv` tasks and `script/e2e_scenarios.rb` - new commonly needed backend states should be added to the scenario/task layer, not recreated ad hoc in each consuming test suite +- the released production image should be exercised through the blackbox task layer, not only Rails-internal test entrypoints ## Practical Expectation diff --git a/docker-compose.blackbox.yml b/docker-compose.blackbox.yml new file mode 100644 index 0000000..acaa2f4 --- /dev/null +++ b/docker-compose.blackbox.yml @@ -0,0 +1,46 @@ +services: + postgres: + image: ${TURNIERE_BLACKBOX_POSTGRES_IMAGE:-postgres:16} + environment: + POSTGRES_DB: ${TURNIERE_BLACKBOX_POSTGRES_DB:-turniere_blackbox} + POSTGRES_USER: ${TURNIERE_BLACKBOX_POSTGRES_USER:-turniere} + POSTGRES_PASSWORD: ${TURNIERE_BLACKBOX_POSTGRES_PASSWORD:-turniere} + healthcheck: + test: ["CMD-SHELL", "pg_isready -U $$POSTGRES_USER -d $$POSTGRES_DB"] + interval: 5s + timeout: 5s + retries: 20 + volumes: + - turniere-blackbox-postgres:/var/lib/postgresql/data + + app: + image: ${TURNIERE_BLACKBOX_APP_IMAGE:-registry.gitlab.com/turniere/turniere-backend/production/commits:local} + depends_on: + postgres: + condition: service_healthy + environment: + POSTGRES_URL: postgres://${TURNIERE_BLACKBOX_POSTGRES_USER:-turniere}:${TURNIERE_BLACKBOX_POSTGRES_PASSWORD:-turniere}@postgres:5432/${TURNIERE_BLACKBOX_POSTGRES_DB:-turniere_blackbox} + POSTGRES_USERNAME: ${TURNIERE_BLACKBOX_POSTGRES_USER:-turniere} + POSTGRES_PASSWORD: ${TURNIERE_BLACKBOX_POSTGRES_PASSWORD:-turniere} + SECRET_KEY_BASE: ${TURNIERE_BLACKBOX_SECRET_KEY_BASE:-turniere-blackbox-secret-key-base-please-change-in-real-prod} + MAILGUN_API_KEY: ${TURNIERE_BLACKBOX_MAILGUN_API_KEY:-blackbox-test-api-key} + MAILGUN_DOMAIN: ${TURNIERE_BLACKBOX_MAILGUN_DOMAIN:-blackbox.example.com} + RAILS_LOG_TO_STDOUT: "1" + RAILS_SERVE_STATIC_FILES: "1" + ports: + - "${TURNIERE_BLACKBOX_HOST_PORT:-3000}:3000" + + e2e: + image: ${TURNIERE_BLACKBOX_RUNNER_IMAGE:-registry.gitlab.com/turniere/turniere-backend/test/commits:local} + depends_on: + app: + condition: service_started + working_dir: /app + environment: + TURNIERE_E2E_BASE_URL: ${TURNIERE_E2E_BASE_URL:-http://app:3000} + TURNIERE_E2E_EMAIL: ${TURNIERE_E2E_EMAIL:-e2e@example.com} + TURNIERE_E2E_PASSWORD: ${TURNIERE_E2E_PASSWORD:-password123} + TURNIERE_E2E_USERNAME: ${TURNIERE_E2E_USERNAME:-e2e-user} + +volumes: + turniere-blackbox-postgres: diff --git a/tasks.py b/tasks.py index d638c88..8b6641b 100644 --- a/tasks.py +++ b/tasks.py @@ -1,6 +1,8 @@ from invoke import task import os +import re +import shlex import signal import subprocess import time @@ -10,6 +12,8 @@ import urllib.request SERVER_HOST = "0.0.0.0" 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" E2E_EMAIL = "e2e@example.com" E2E_PASSWORD = "password123" @@ -18,6 +22,15 @@ E2E_USERNAME = "e2e-user" PRODUCTION_TAG = "registry.gitlab.com/turniere/turniere-backend/production/commits:local" DEVELOPMENT_TAG = "registry.gitlab.com/turniere/turniere-backend/development/commits:local" TEST_TAG = "registry.gitlab.com/turniere/turniere-backend/test/commits:local" +BLACKBOX_COMPOSE_FILE = "docker-compose.blackbox.yml" +BLACKBOX_PROJECT = "turniere-blackbox" +BLACKBOX_POSTGRES_IMAGE = "postgres:16" +BLACKBOX_DB_NAME = "turniere_blackbox" +BLACKBOX_DB_USER = "turniere" +BLACKBOX_DB_PASSWORD = "turniere" +BLACKBOX_SECRET_KEY_BASE = "turniere-blackbox-secret-key-base-please-change-in-real-prod" +BLACKBOX_MAILGUN_API_KEY = "blackbox-test-api-key" +BLACKBOX_MAILGUN_DOMAIN = "blackbox.example.com" def _env(**overrides): @@ -41,6 +54,162 @@ def _wait_for_http(base_url=TEST_BASE_URL, timeout=60): raise RuntimeError(f"healthcheck did not become ready within {timeout}s: {healthz_url}") +def _docker_compose_cmd(project_name=BLACKBOX_PROJECT, compose_file=BLACKBOX_COMPOSE_FILE): + return ["docker-compose", "-p", project_name, "-f", compose_file] + + +def _run_subprocess(command, env=None, capture_output=False, check=True): + completed = subprocess.run( + command, + env=env, + text=True, + capture_output=capture_output, + check=False, + ) + if check and completed.returncode != 0: + raise subprocess.CalledProcessError( + completed.returncode, + command, + output=completed.stdout, + stderr=completed.stderr, + ) + return completed + + +def _compose_env( + host_port=SERVER_PORT, + app_image=PRODUCTION_TAG, + runner_image=TEST_TAG, + postgres_image=BLACKBOX_POSTGRES_IMAGE, +): + return _env( + TURNIERE_BLACKBOX_HOST_PORT=host_port, + TURNIERE_BLACKBOX_APP_IMAGE=app_image, + TURNIERE_BLACKBOX_RUNNER_IMAGE=runner_image, + TURNIERE_BLACKBOX_POSTGRES_IMAGE=postgres_image, + TURNIERE_BLACKBOX_POSTGRES_DB=BLACKBOX_DB_NAME, + TURNIERE_BLACKBOX_POSTGRES_USER=BLACKBOX_DB_USER, + TURNIERE_BLACKBOX_POSTGRES_PASSWORD=BLACKBOX_DB_PASSWORD, + TURNIERE_BLACKBOX_SECRET_KEY_BASE=BLACKBOX_SECRET_KEY_BASE, + TURNIERE_BLACKBOX_MAILGUN_API_KEY=BLACKBOX_MAILGUN_API_KEY, + TURNIERE_BLACKBOX_MAILGUN_DOMAIN=BLACKBOX_MAILGUN_DOMAIN, + TURNIERE_E2E_BASE_URL=BLACKBOX_INTERNAL_BASE_URL, + TURNIERE_E2E_EMAIL=E2E_EMAIL, + TURNIERE_E2E_PASSWORD=E2E_PASSWORD, + TURNIERE_E2E_USERNAME=E2E_USERNAME, + ) + + +def _format_command(command): + return " ".join(shlex.quote(part) for part in command) + + +def _compose_run(compose_args, env, capture_output=False, check=True): + command = _docker_compose_cmd() + compose_args + return _run_subprocess(command, env=env, capture_output=capture_output, check=check) + + +def _print_header(title): + print(f"\n== {title} ==") + + +def _bootstrap_user_command(email, password, username): + return ( + "bundle exec rails runner " + f"\"user = User.find_or_initialize_by(email: '{email}'); " + f"user.username = '{username}'; " + f"user.password = '{password}'; " + f"user.password_confirmation = '{password}'; " + "user.confirmed_at = Time.current; " + "user.uid = user.email; " + "user.provider = 'email'; " + "user.save!\"" + ) + + +def _parse_rspec_report(output): + summary_match = re.search(r"(\d+)\s+examples?,\s+(\d+)\s+failures?(?:,\s+(\d+)\s+pending)?", output) + failed_examples = re.findall(r"^\s*rspec\s+(.+)$", output, flags=re.MULTILINE) + + return { + "examples": int(summary_match.group(1)) if summary_match else None, + "failures": int(summary_match.group(2)) if summary_match else None, + "pending": int(summary_match.group(3)) if summary_match and summary_match.group(3) else 0, + "failed_examples": failed_examples, + } + + +def _print_rspec_report(report): + examples = report["examples"] + failures = report["failures"] + pending = report["pending"] + failed_examples = report["failed_examples"] + + if examples is None or failures is None: + print("Rerun summary could not be parsed from the RSpec output.") + return + + print(f"Examples: {examples}") + print(f"Failures: {failures}") + print(f"Pending: {pending}") + if failed_examples: + print("Failed examples:") + for example in failed_examples: + print(f"- {example}") + + +def _print_service_logs(env, service, tail=120): + _print_header(f"{service} logs (last {tail} lines)") + logs = _compose_run(["logs", "--tail", str(tail), service], env=env, capture_output=True, check=False) + output = logs.stdout or logs.stderr or "" + print(output.rstrip()) + + +def _shared_production_env(): + return _env( + RAILS_ENV="production", + POSTGRES_URL=os.environ.get( + "POSTGRES_URL", + f"postgres://{BLACKBOX_DB_USER}:{BLACKBOX_DB_PASSWORD}@postgres:5432/{BLACKBOX_DB_NAME}", + ), + POSTGRES_USERNAME=os.environ.get("POSTGRES_USERNAME", BLACKBOX_DB_USER), + POSTGRES_PASSWORD=os.environ.get("POSTGRES_PASSWORD", BLACKBOX_DB_PASSWORD), + SECRET_KEY_BASE=os.environ.get("SECRET_KEY_BASE", BLACKBOX_SECRET_KEY_BASE), + MAILGUN_API_KEY=os.environ.get("MAILGUN_API_KEY", BLACKBOX_MAILGUN_API_KEY), + MAILGUN_DOMAIN=os.environ.get("MAILGUN_DOMAIN", BLACKBOX_MAILGUN_DOMAIN), + RAILS_LOG_TO_STDOUT=os.environ.get("RAILS_LOG_TO_STDOUT", "1"), + RAILS_SERVE_STATIC_FILES=os.environ.get("RAILS_SERVE_STATIC_FILES", "1"), + ) + + +def _run_blackbox_rspec(base_url, email, password, username): + env = _env( + TURNIERE_E2E_BASE_URL=base_url, + TURNIERE_E2E_EMAIL=email, + TURNIERE_E2E_PASSWORD=password, + TURNIERE_E2E_USERNAME=username, + ) + command = ["bundle", "exec", "rspec", "spec/e2e/http", "--format", "documentation"] + print(_format_command(command)) + result = _run_subprocess(command, env=env, capture_output=True, check=False) + if result.stdout: + print(result.stdout.rstrip()) + if result.stderr: + print(result.stderr.rstrip()) + + report = _parse_rspec_report(f"{result.stdout}\n{result.stderr}") + _print_header("Blackbox report") + _print_rspec_report(report) + + if result.returncode != 0: + raise subprocess.CalledProcessError( + result.returncode, + result.args, + output=result.stdout, + stderr=result.stderr, + ) + + def _start_test_server(): log_handle = open("/tmp/turniere-e2e-server.log", "w") process = subprocess.Popen( @@ -260,6 +429,202 @@ def docker_build_all(c): docker_build_test(c) +@task(name="docker-blackbox-up") +def docker_blackbox_up( + c, + host_port=SERVER_PORT, + app_image=PRODUCTION_TAG, + runner_image=TEST_TAG, + postgres_image=BLACKBOX_POSTGRES_IMAGE, + build=True, +): + """Build the Docker images, then boot Postgres and the production app for blackbox testing.""" + if build: + docker_build_all(c) + + env = _compose_env( + host_port=host_port, + app_image=app_image, + runner_image=runner_image, + postgres_image=postgres_image, + ) + + _print_header("Starting production blackbox stack") + _compose_run(["down", "-v", "--remove-orphans"], env=env, check=False) + _compose_run(["up", "-d", "postgres", "app"], env=env) + + base_url = f"http://127.0.0.1:{host_port}" + _print_header(f"Waiting for app healthcheck at {base_url}") + _wait_for_http(base_url=base_url, timeout=120) + + _print_header("Bootstrapping confirmed E2E user") + bootstrap = _compose_run( + ["exec", "-T", "app", "bash", "-lc", _bootstrap_user_command(E2E_EMAIL, E2E_PASSWORD, E2E_USERNAME)], + env=env, + capture_output=True, + check=False, + ) + if bootstrap.returncode != 0: + print(bootstrap.stdout or "", end="") + print(bootstrap.stderr or "", end="") + raise subprocess.CalledProcessError( + bootstrap.returncode, + bootstrap.args, + output=bootstrap.stdout, + stderr=bootstrap.stderr, + ) + + print("Production app is ready for blackbox E2E.") + + +@task(name="docker-blackbox-down") +def docker_blackbox_down( + c, + host_port=SERVER_PORT, + app_image=PRODUCTION_TAG, + runner_image=TEST_TAG, + postgres_image=BLACKBOX_POSTGRES_IMAGE, +): + """Stop and remove the production blackbox stack.""" + env = _compose_env( + host_port=host_port, + app_image=app_image, + runner_image=runner_image, + postgres_image=postgres_image, + ) + _compose_run(["down", "-v", "--remove-orphans"], env=env, check=False) + + +@task(name="docker-blackbox-test") +def docker_blackbox_test( + c, + host_port=SERVER_PORT, + app_image=PRODUCTION_TAG, + runner_image=TEST_TAG, + postgres_image=BLACKBOX_POSTGRES_IMAGE, +): + """Run the HTTP E2E suite against the running production blackbox stack.""" + env = _compose_env( + host_port=host_port, + app_image=app_image, + runner_image=runner_image, + postgres_image=postgres_image, + ) + + _print_header("Running HTTP E2E against production image") + command = [ + "run", + "--rm", + "e2e", + "bundle", + "exec", + "rspec", + "spec/e2e/http", + "--format", + "documentation", + ] + print(_format_command(_docker_compose_cmd() + command)) + result = _compose_run(command, env=env, capture_output=True, check=False) + if result.stdout: + print(result.stdout.rstrip()) + if result.stderr: + print(result.stderr.rstrip()) + + report = _parse_rspec_report(f"{result.stdout}\n{result.stderr}") + _print_header("Blackbox report") + _print_rspec_report(report) + + if result.returncode != 0: + raise subprocess.CalledProcessError( + result.returncode, + result.args, + output=result.stdout, + stderr=result.stderr, + ) + + +@task(name="blackbox-service") +def blackbox_service( + c, + base_url=BLACKBOX_INTERNAL_BASE_URL, + email=E2E_EMAIL, + password=E2E_PASSWORD, + username=E2E_USERNAME, +): + """Run blackbox HTTP E2E against an already running production app plus Postgres sidecars.""" + _print_header(f"Waiting for app healthcheck at {base_url}") + _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, + ) + + _print_header("Running HTTP E2E against production image") + _run_blackbox_rspec(base_url, email, password, username) + + +@task(name="blackbox-production") +def blackbox_production( + c, + host_port=SERVER_PORT, + app_image=PRODUCTION_TAG, + runner_image=TEST_TAG, + postgres_image=BLACKBOX_POSTGRES_IMAGE, + build=True, + keep_running=False, +): + """Build the production image, boot a Postgres-backed stack, run HTTP E2E, and print a summary.""" + env = _compose_env( + host_port=host_port, + app_image=app_image, + runner_image=runner_image, + postgres_image=postgres_image, + ) + + exit_error = None + try: + docker_blackbox_up( + c, + host_port=host_port, + app_image=app_image, + runner_image=runner_image, + postgres_image=postgres_image, + build=build, + ) + docker_blackbox_test( + c, + host_port=host_port, + app_image=app_image, + runner_image=runner_image, + postgres_image=postgres_image, + ) + except (RuntimeError, subprocess.CalledProcessError) as error: + exit_error = error + _print_service_logs(env, "app") + _print_service_logs(env, "postgres") + finally: + if keep_running: + _print_header("Stack kept running") + print( + f"Production app: http://127.0.0.1:{host_port}\n" + f"Stop it later with: {_format_command(_docker_compose_cmd() + ['down', '-v', '--remove-orphans'])}" + ) + else: + docker_blackbox_down( + c, + host_port=host_port, + app_image=app_image, + runner_image=runner_image, + postgres_image=postgres_image, + ) + + if exit_error: + raise exit_error + + @task(name="docker-test-http-e2e") def docker_test_http_e2e( c, From 031f40a85d2231abb0c81a4382a3d709d802f05c Mon Sep 17 00:00:00 2001 From: Malaber Date: Sat, 11 Apr 2026 15:40:56 +0200 Subject: [PATCH 08/18] Shard Rails specs across 8 CI jobs --- .gitlab-ci.yml | 6 ++++- AGENTS.md | 2 ++ tasks.py | 64 ++++++++++++++++++++++++++++++++++++++++++++++++++ 3 files changed, 71 insertions(+), 1 deletion(-) diff --git a/.gitlab-ci.yml b/.gitlab-ci.yml index 5e92881..ff0f65e 100644 --- a/.gitlab-ci.yml +++ b/.gitlab-ci.yml @@ -10,6 +10,10 @@ include: rails spec: stage: test image: $CI_REGISTRY_IMAGE/test/commits:$CI_COMMIT_SHA + parallel: + matrix: + - RSPEC_NODE_INDEX: ["1", "2", "3", "4", "5", "6", "7", "8"] + RSPEC_NODE_TOTAL: "8" rules: - if: $SKIP_TEST when: never @@ -19,7 +23,7 @@ rails spec: script: - cd /app - inv db-migrate - - inv test + - inv test-shard --node-index=$RSPEC_NODE_INDEX --node-total=$RSPEC_NODE_TOTAL artifacts: paths: - specs_with_runtime.txt diff --git a/AGENTS.md b/AGENTS.md index 146eb18..f4d9a9d 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -13,6 +13,7 @@ Examples: - `inv db-migrate` - `inv test` +- `inv test-shard --node-index=1 --node-total=8` - `inv lint` - `inv verify-http` - `inv blackbox-production` @@ -22,6 +23,7 @@ Examples: The GitLab CI pipeline is expected to use these tasks as well. In CI, blackbox E2E should target sidecar service containers rather than Docker Compose inside the job. +The normal Rails spec suite is sharded through `inv test-shard ...` and should stay aligned with the CI matrix configuration. This is not just a convenience preference. The task layer is the operational contract for this repo and should stay aligned across local use, CI, and diff --git a/tasks.py b/tasks.py index 8b6641b..1d61fed 100644 --- a/tasks.py +++ b/tasks.py @@ -7,6 +7,7 @@ import signal import subprocess import time import urllib.request +from pathlib import Path SERVER_HOST = "0.0.0.0" @@ -31,6 +32,8 @@ BLACKBOX_DB_PASSWORD = "turniere" BLACKBOX_SECRET_KEY_BASE = "turniere-blackbox-secret-key-base-please-change-in-real-prod" BLACKBOX_MAILGUN_API_KEY = "blackbox-test-api-key" BLACKBOX_MAILGUN_DOMAIN = "blackbox.example.com" +SPEC_ROOT = Path("spec") +E2E_SPEC_ROOT = SPEC_ROOT / "e2e" / "http" def _env(**overrides): @@ -165,6 +168,35 @@ def _print_service_logs(env, service, tail=120): print(output.rstrip()) +def _spec_files(): + return sorted( + path for path in SPEC_ROOT.rglob("*_spec.rb") + if not path.is_relative_to(E2E_SPEC_ROOT) + ) + + +def _spec_weight(path): + return sum(1 for _ in path.open("r", encoding="utf-8")) + + +def _spec_shards(node_total): + if node_total <= 0: + raise ValueError("node_total must be positive") + + shards = [{"weight": 0, "files": []} for _ in range(node_total)] + weighted_files = sorted( + ((path, _spec_weight(path)) for path in _spec_files()), + key=lambda item: (-item[1], str(item[0])), + ) + + for path, weight in weighted_files: + shard = min(shards, key=lambda item: (item["weight"], len(item["files"]))) + shard["files"].append(path) + shard["weight"] += weight + + return shards + + def _shared_production_env(): return _env( RAILS_ENV="production", @@ -261,6 +293,38 @@ def test(c): c.run("bundle exec rspec", pty=True) +@task(name="test-shard") +def test_shard(c, node_index=1, node_total=8, dry_run=False): + """Run one deterministic shard of the non-E2E RSpec suite.""" + node_index = int(node_index) + node_total = int(node_total) + + if node_index < 1 or node_index > node_total: + raise ValueError(f"node_index must be between 1 and {node_total}, got {node_index}") + + shards = _spec_shards(node_total) + shard = shards[node_index - 1] + files = [str(path) for path in shard["files"]] + + _print_header(f"RSpec shard {node_index}/{node_total}") + print(f"Files: {len(files)}") + print(f"Estimated weight: {shard['weight']}") + if files: + print("Included specs:") + for path in files: + print(f"- {path}") + else: + print("No spec files assigned to this shard.") + + if dry_run: + return + + if not files: + return + + c.run("bundle exec rspec " + " ".join(shlex.quote(path) for path in files), pty=True) + + @task def lint(c): """Run RuboCop.""" From 71cedbd33dbcd96a54369e55272ff77978fd99a6 Mon Sep 17 00:00:00 2001 From: Malaber Date: Sat, 11 Apr 2026 15:50:19 +0200 Subject: [PATCH 09/18] Fix isolated shard loading and docker shard task --- AGENTS.md | 1 + tasks.py | 19 ++++++++++++++++++- 2 files changed, 19 insertions(+), 1 deletion(-) diff --git a/AGENTS.md b/AGENTS.md index f4d9a9d..54c756d 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -19,6 +19,7 @@ Examples: - `inv blackbox-production` - `inv scenario-main-usecase` - `inv docker-build-all` +- `inv docker-test-shard --node-index=3 --node-total=8` - `inv docker-test-http-e2e` The GitLab CI pipeline is expected to use these tasks as well. diff --git a/tasks.py b/tasks.py index 1d61fed..0721fc9 100644 --- a/tasks.py +++ b/tasks.py @@ -322,7 +322,10 @@ def test_shard(c, node_index=1, node_total=8, dry_run=False): if not files: return - c.run("bundle exec rspec " + " ".join(shlex.quote(path) for path in files), pty=True) + c.run( + "bundle exec rspec --require ./spec/rails_helper " + " ".join(shlex.quote(path) for path in files), + pty=True, + ) @task @@ -722,3 +725,17 @@ def docker_test_http_e2e( "kill \\$SERVER_PID\"" ) c.run(command, pty=True) + + +@task(name="docker-test-shard") +def docker_test_shard(c, node_index=1, node_total=8, image=TEST_TAG, build=True): + """Run one Rails-spec shard inside the test Docker image.""" + if build: + docker_build_all(c) + + command = ( + f"docker run --rm {image} bash -lc " + f"\"cd /app && bundle exec rails db:migrate && " + f"inv test-shard --node-index={node_index} --node-total={node_total}\"" + ) + c.run(command, pty=True) From dc60e672b896ab12c1057d0bb55d1794fc46c72a Mon Sep 17 00:00:00 2001 From: Malaber Date: Sat, 11 Apr 2026 15:51:57 +0200 Subject: [PATCH 10/18] Make blackbox app service wait for postgres --- .gitlab-ci.yml | 9 +++++++++ docker/production/Dockerfile | 1 + 2 files changed, 10 insertions(+) diff --git a/.gitlab-ci.yml b/.gitlab-ci.yml index ff0f65e..997e828 100644 --- a/.gitlab-ci.yml +++ b/.gitlab-ci.yml @@ -37,6 +37,15 @@ rails e2e spec: alias: postgres - name: $CI_REGISTRY_IMAGE/production/commits:$CI_COMMIT_SHA alias: app + entrypoint: + - /bin/bash + - -lc + command: + - | + until getent hosts postgres >/dev/null 2>&1; do sleep 1; done + until bundle exec ruby -rpg -e "PG.connect(ENV.fetch('POSTGRES_URL')).close" >/dev/null 2>&1; do sleep 1; done + bundle exec rails db:migrate + exec bundle exec rails s -p 3000 -b 0.0.0.0 variables: POSTGRES_DB: turniere_blackbox POSTGRES_USER: turniere diff --git a/docker/production/Dockerfile b/docker/production/Dockerfile index 6840fc3..a49494d 100644 --- a/docker/production/Dockerfile +++ b/docker/production/Dockerfile @@ -9,4 +9,5 @@ COPY . /app # Duplicate production environment to beta environment COPY config/environments/production.rb config/environments/beta.rb ENV RAILS_ENV production +EXPOSE 3000 CMD bundle exec rails db:migrate && bundle exec rails s -p 3000 -b 0.0.0.0 From 4242c1ccd0d4f33959a32f27a58ac33e35edb5dd Mon Sep 17 00:00:00 2001 From: Malaber Date: Sat, 11 Apr 2026 16:12:39 +0200 Subject: [PATCH 11/18] Move blackbox migration into CI job --- .gitlab-ci.yml | 16 ++++++++-------- tasks.py | 3 +++ 2 files changed, 11 insertions(+), 8 deletions(-) diff --git a/.gitlab-ci.yml b/.gitlab-ci.yml index 997e828..9366723 100644 --- a/.gitlab-ci.yml +++ b/.gitlab-ci.yml @@ -37,15 +37,15 @@ rails e2e spec: alias: postgres - name: $CI_REGISTRY_IMAGE/production/commits:$CI_COMMIT_SHA alias: app - entrypoint: - - /bin/bash - - -lc command: - - | - until getent hosts postgres >/dev/null 2>&1; do sleep 1; done - until bundle exec ruby -rpg -e "PG.connect(ENV.fetch('POSTGRES_URL')).close" >/dev/null 2>&1; do sleep 1; done - bundle exec rails db:migrate - exec bundle exec rails s -p 3000 -b 0.0.0.0 + - bundle + - exec + - rails + - s + - -p + - "3000" + - -b + - 0.0.0.0 variables: POSTGRES_DB: turniere_blackbox POSTGRES_USER: turniere diff --git a/tasks.py b/tasks.py index 0721fc9..60e42c4 100644 --- a/tasks.py +++ b/tasks.py @@ -619,6 +619,9 @@ def blackbox_service( username=E2E_USERNAME, ): """Run blackbox HTTP E2E against an already running production app plus Postgres sidecars.""" + _print_header("Running production database migrations") + c.run("bundle exec rails db:migrate", env=_shared_production_env(), pty=True) + _print_header(f"Waiting for app healthcheck at {base_url}") _wait_for_http(base_url=base_url, timeout=120) From a3e1f3b05c53b7a8a9df5876a8ffc18b7cc8f987 Mon Sep 17 00:00:00 2001 From: Malaber Date: Sat, 11 Apr 2026 16:14:28 +0200 Subject: [PATCH 12/18] Add CI spec skip variables --- .gitlab-ci.yml | 8 ++++++++ AGENTS.md | 8 ++++++++ 2 files changed, 16 insertions(+) diff --git a/.gitlab-ci.yml b/.gitlab-ci.yml index 9366723..2db4e4d 100644 --- a/.gitlab-ci.yml +++ b/.gitlab-ci.yml @@ -17,6 +17,10 @@ rails spec: rules: - if: $SKIP_TEST when: never + - if: $SKIP_ALL_SPECS + when: never + - if: $SKIP_RAILS_SPEC + when: never - if: $CI_PIPELINE_SOURCE != "push" when: never - when: always @@ -64,6 +68,10 @@ rails e2e spec: 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 diff --git a/AGENTS.md b/AGENTS.md index 54c756d..7027e6b 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -26,6 +26,14 @@ The GitLab CI pipeline is expected to use these tasks as well. In CI, blackbox E2E should target sidecar service containers rather than Docker Compose inside the job. The normal Rails spec suite is sharded through `inv test-shard ...` and should stay aligned with the CI matrix configuration. +Useful GitLab push-option variables for saving CI minutes on public repos: + +- skip all spec jobs: `git push -o ci.variable="SKIP_ALL_SPECS=1"` +- skip only normal Rails specs: `git push -o ci.variable="SKIP_RAILS_SPEC=1"` +- skip only blackbox E2E: `git push -o ci.variable="SKIP_E2E_SPEC=1"` + +Do not use these by default. Only use them when the change truly does not affect the skipped area. + This is not just a convenience preference. The task layer is the operational contract for this repo and should stay aligned across local use, CI, and cross-repo consumers. From 7f07ededcfbc687b0d34f9a56af6be4cd2e0cc79 Mon Sep 17 00:00:00 2001 From: Malaber Date: Sat, 11 Apr 2026 16:34:32 +0200 Subject: [PATCH 13/18] Bypass proxies for blackbox healthcheck --- .gitlab-ci.yml | 2 ++ tasks.py | 3 ++- 2 files changed, 4 insertions(+), 1 deletion(-) diff --git a/.gitlab-ci.yml b/.gitlab-ci.yml index 2db4e4d..f6d5abe 100644 --- a/.gitlab-ci.yml +++ b/.gitlab-ci.yml @@ -61,6 +61,8 @@ rails e2e spec: MAILGUN_DOMAIN: blackbox.example.com RAILS_LOG_TO_STDOUT: "1" RAILS_SERVE_STATIC_FILES: "1" + NO_PROXY: app,postgres,localhost,127.0.0.1 + no_proxy: app,postgres,localhost,127.0.0.1 TURNIERE_E2E_BASE_URL: http://app:3000 TURNIERE_E2E_EMAIL: e2e@example.com TURNIERE_E2E_PASSWORD: password123 diff --git a/tasks.py b/tasks.py index 60e42c4..aa85ee6 100644 --- a/tasks.py +++ b/tasks.py @@ -45,10 +45,11 @@ def _env(**overrides): def _wait_for_http(base_url=TEST_BASE_URL, timeout=60): deadline = time.time() + timeout healthz_url = f"{base_url}/healthz" + opener = urllib.request.build_opener(urllib.request.ProxyHandler({})) while time.time() < deadline: try: - with urllib.request.urlopen(healthz_url) as response: + with opener.open(healthz_url) as response: if response.status == 200: return except Exception: From ea43f96898753fc951f8172cfe752d2f0d3fadd9 Mon Sep 17 00:00:00 2001 From: Malaber Date: Sat, 11 Apr 2026 17:00:50 +0200 Subject: [PATCH 14/18] Pass app service env and improve health diagnostics --- .gitlab-ci.yml | 9 +++++++++ tasks.py | 13 +++++++++++-- 2 files changed, 20 insertions(+), 2 deletions(-) diff --git a/.gitlab-ci.yml b/.gitlab-ci.yml index f6d5abe..9e825c8 100644 --- a/.gitlab-ci.yml +++ b/.gitlab-ci.yml @@ -41,6 +41,15 @@ rails e2e spec: alias: postgres - name: $CI_REGISTRY_IMAGE/production/commits:$CI_COMMIT_SHA alias: app + variables: + POSTGRES_URL: postgres://turniere:turniere@postgres:5432/turniere_blackbox + POSTGRES_USERNAME: turniere + POSTGRES_PASSWORD: turniere + SECRET_KEY_BASE: turniere-blackbox-secret-key-base-please-change-in-real-prod + MAILGUN_API_KEY: blackbox-test-api-key + MAILGUN_DOMAIN: blackbox.example.com + RAILS_LOG_TO_STDOUT: "1" + RAILS_SERVE_STATIC_FILES: "1" command: - bundle - exec diff --git a/tasks.py b/tasks.py index aa85ee6..1b71ac1 100644 --- a/tasks.py +++ b/tasks.py @@ -7,6 +7,7 @@ import signal import subprocess import time import urllib.request +import urllib.error from pathlib import Path @@ -46,16 +47,24 @@ def _wait_for_http(base_url=TEST_BASE_URL, timeout=60): deadline = time.time() + timeout healthz_url = f"{base_url}/healthz" opener = urllib.request.build_opener(urllib.request.ProxyHandler({})) + last_error = None while time.time() < deadline: try: with opener.open(healthz_url) as response: if response.status == 200: return - except Exception: + last_error = f"unexpected status {response.status}" + except urllib.error.HTTPError as error: + body = error.read().decode("utf-8", errors="replace") + last_error = f"http {error.code}: {body}" + time.sleep(0.5) + except Exception as error: + last_error = repr(error) time.sleep(0.5) - raise RuntimeError(f"healthcheck did not become ready within {timeout}s: {healthz_url}") + detail = f" last error: {last_error}" if last_error else "" + raise RuntimeError(f"healthcheck did not become ready within {timeout}s: {healthz_url}.{detail}") def _docker_compose_cmd(project_name=BLACKBOX_PROJECT, compose_file=BLACKBOX_COMPOSE_FILE): From 8c47b270a44666d52419cd3c6e02164fa74d7ae4 Mon Sep 17 00:00:00 2001 From: Malaber Date: Sat, 11 Apr 2026 17:12:23 +0200 Subject: [PATCH 15/18] Run GitLab blackbox e2e inside production image --- .gitlab-ci.yml | 30 +++------------- AGENTS.md | 3 +- script/verify_e2e_blackbox.rb | 65 +++++++++++++++++++++++++++++++++++ tasks.py | 57 +++++++++++++++++++++++++++--- 4 files changed, 125 insertions(+), 30 deletions(-) create mode 100644 script/verify_e2e_blackbox.rb diff --git a/.gitlab-ci.yml b/.gitlab-ci.yml index 9e825c8..feaf8dd 100644 --- a/.gitlab-ci.yml +++ b/.gitlab-ci.yml @@ -35,30 +35,10 @@ rails spec: rails e2e spec: stage: test - image: $CI_REGISTRY_IMAGE/test/commits:$CI_COMMIT_SHA + image: $CI_REGISTRY_IMAGE/production/commits:$CI_COMMIT_SHA services: - name: postgres:16 alias: postgres - - name: $CI_REGISTRY_IMAGE/production/commits:$CI_COMMIT_SHA - alias: app - variables: - POSTGRES_URL: postgres://turniere:turniere@postgres:5432/turniere_blackbox - POSTGRES_USERNAME: turniere - POSTGRES_PASSWORD: turniere - SECRET_KEY_BASE: turniere-blackbox-secret-key-base-please-change-in-real-prod - MAILGUN_API_KEY: blackbox-test-api-key - MAILGUN_DOMAIN: blackbox.example.com - RAILS_LOG_TO_STDOUT: "1" - RAILS_SERVE_STATIC_FILES: "1" - command: - - bundle - - exec - - rails - - s - - -p - - "3000" - - -b - - 0.0.0.0 variables: POSTGRES_DB: turniere_blackbox POSTGRES_USER: turniere @@ -70,9 +50,9 @@ rails e2e spec: MAILGUN_DOMAIN: blackbox.example.com RAILS_LOG_TO_STDOUT: "1" RAILS_SERVE_STATIC_FILES: "1" - NO_PROXY: app,postgres,localhost,127.0.0.1 - no_proxy: app,postgres,localhost,127.0.0.1 - TURNIERE_E2E_BASE_URL: http://app:3000 + NO_PROXY: postgres,localhost,127.0.0.1 + no_proxy: postgres,localhost,127.0.0.1 + TURNIERE_E2E_BASE_URL: http://127.0.0.1:3000 TURNIERE_E2E_EMAIL: e2e@example.com TURNIERE_E2E_PASSWORD: password123 TURNIERE_E2E_USERNAME: e2e-user @@ -88,4 +68,4 @@ rails e2e spec: - when: always script: - cd /app - - inv blackbox-service + - inv blackbox-self diff --git a/AGENTS.md b/AGENTS.md index 7027e6b..a849811 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -16,6 +16,7 @@ Examples: - `inv test-shard --node-index=1 --node-total=8` - `inv lint` - `inv verify-http` +- `inv blackbox-self` - `inv blackbox-production` - `inv scenario-main-usecase` - `inv docker-build-all` @@ -23,7 +24,7 @@ Examples: - `inv docker-test-http-e2e` The GitLab CI pipeline is expected to use these tasks as well. -In CI, blackbox E2E should target sidecar service containers rather than Docker Compose inside the job. +In CI, blackbox E2E should run from the production image with Postgres as the sidecar and should avoid Docker Compose inside the job. The normal Rails spec suite is sharded through `inv test-shard ...` and should stay aligned with the CI matrix configuration. Useful GitLab push-option variables for saving CI minutes on public repos: diff --git a/script/verify_e2e_blackbox.rb b/script/verify_e2e_blackbox.rb new file mode 100644 index 0000000..acf91bf --- /dev/null +++ b/script/verify_e2e_blackbox.rb @@ -0,0 +1,65 @@ +#!/usr/bin/env ruby +# frozen_string_literal: true + +require_relative '../e2e/lib/scenario_runner' + +def assert_equal(expected, actual, label) + return if expected == actual + + raise "#{label}: expected #{expected.inspect}, got #{actual.inspect}" +end + +def assert(condition, label) + return if condition + + raise label +end + +runner = TurniereE2E::ScenarioRunner.new( + base_url: ENV.fetch('TURNIERE_E2E_BASE_URL', 'http://127.0.0.1:3000'), + email: ENV.fetch('TURNIERE_E2E_EMAIL'), + password: ENV.fetch('TURNIERE_E2E_PASSWORD'), + username: ENV['TURNIERE_E2E_USERNAME'] +) + +puts 'Verifying main 4x4 group-stage lifecycle...' +main_result = runner.run_group_stage_main_usecase +assert_equal(8, main_result.dig(:checkpoints, :created, :playoff_teams_amount), 'main playoff_teams_amount') +assert_equal(8, main_result.dig(:checkpoints, :created, :instant_finalists_amount), 'main instant_finalists_amount') +assert_equal(0, main_result.dig(:checkpoints, :created, :intermediate_round_participants_amount), 'main intermediate_round_participants_amount') +assert_equal(4, main_result.dig(:checkpoints, :upcoming_group_matches, :match_ids).count, 'main upcoming_group_matches count') +assert_equal(16, main_result.dig(:checkpoints, :statistics, :group_scores_count), 'main group_scores_count') +main_playoff_entry_stage = main_result.dig(:checkpoints, :playoffs_created, :stage_summaries) + .select { |stage| stage[:level] != -1 } + .max_by { |stage| stage[:level] } +assert_equal(4, main_playoff_entry_stage[:match_states].count, 'main playoff entry match count') +main_final_attempt = main_result.dig(:checkpoints, :final_finish_attempted, :finish_attempt) +assert_equal(422, main_final_attempt[:status], 'main final finish status') +assert_equal('Moving Team one stage down failed', main_final_attempt.dig(:body, :error), 'main final finish error') + +puts 'Verifying 3-group intermediate-round lifecycle...' +intermediate_result = runner.run_group_stage_intermediate_round +intermediate_created = intermediate_result.fetch(:checkpoints).fetch(:created) +assert_equal(4, intermediate_created[:playoff_teams_amount], 'intermediate playoff_teams_amount') +assert_equal(3, intermediate_created[:instant_finalists_amount], 'intermediate instant_finalists_amount') +assert_equal(2, intermediate_created[:intermediate_round_participants_amount], 'intermediate intermediate_round_participants_amount') +intermediate_playoff_entry_stage = intermediate_result.dig(:checkpoints, :playoffs_created, :stage_summaries) + .select { |stage| stage[:level] != -1 } + .max_by { |stage| stage[:level] } +assert_equal('intermediate_stage', intermediate_playoff_entry_stage[:state], 'intermediate stage state') +assert_equal(3, intermediate_playoff_entry_stage[:match_states].count('single_team'), 'intermediate single_team count') +assert_equal(1, intermediate_playoff_entry_stage[:match_states].count('not_started'), 'intermediate not_started count') +intermediate_final_attempt = intermediate_result.dig(:checkpoints, :final_finish_attempted, :finish_attempt) +assert_equal(422, intermediate_final_attempt[:status], 'intermediate final finish status') +assert_equal('Moving Team one stage down failed', intermediate_final_attempt.dig(:body, :error), 'intermediate final finish error') + +[2, 3, 5, 8].each do |team_count| + puts "Verifying playoff-only lifecycle for #{team_count} teams..." + playoff_result = runner.run_playoff_only(team_count: team_count) + final_stage = playoff_result.dig(:checkpoints, :completed, :stage_summaries) + .find { |stage| stage[:level] == 0 } + assert(final_stage, "missing final stage for playoff-only #{team_count}") + assert_equal(['finished'], final_stage[:match_states], "playoff-only #{team_count} final stage") +end + +puts 'Blackbox verification passed.' diff --git a/tasks.py b/tasks.py index 1b71ac1..45926f3 100644 --- a/tasks.py +++ b/tasks.py @@ -252,11 +252,11 @@ def _run_blackbox_rspec(base_url, email, password, username): ) -def _start_test_server(): - log_handle = open("/tmp/turniere-e2e-server.log", "w") +def _start_server(command, env, log_path="/tmp/turniere-e2e-server.log"): + log_handle = open(log_path, "w") process = subprocess.Popen( - f"bundle exec rails s -e test -b {SERVER_HOST} -p {SERVER_PORT}", - shell=True, + ["/bin/sh", "-lc", command], + env=env, stdout=log_handle, stderr=subprocess.STDOUT, preexec_fn=os.setsid, @@ -264,6 +264,13 @@ def _start_test_server(): return process, log_handle +def _start_test_server(): + return _start_server( + f"bundle exec rails s -e test -b {SERVER_HOST} -p {SERVER_PORT}", + _env(), + ) + + def _stop_process(process, log_handle): try: os.killpg(os.getpgid(process.pid), signal.SIGTERM) @@ -646,6 +653,48 @@ def blackbox_service( _run_blackbox_rspec(base_url, email, password, username) +@task(name="blackbox-self") +def blackbox_self( + c, + base_url=TEST_BASE_URL, + email=E2E_EMAIL, + password=E2E_PASSWORD, + username=E2E_USERNAME, +): + """Run the pure-Ruby blackbox verifier inside the production image against a local production server.""" + env = _shared_production_env() + server_command = f"bundle exec rails s -b {SERVER_HOST} -p {SERVER_PORT}" + + _print_header("Running production database migrations") + c.run("bundle exec rails db:migrate", env=env, pty=True) + + process, log_handle = _start_server(server_command, env) + try: + _print_header(f"Waiting for app healthcheck at {base_url}") + _wait_for_http(base_url=base_url, timeout=120) + + _print_header("Bootstrapping confirmed E2E user") + c.run( + _bootstrap_user_command(email, password, username), + env=env, + pty=True, + ) + + _print_header("Running blackbox verifier") + c.run( + "ruby script/verify_e2e_blackbox.rb", + env=_env( + TURNIERE_E2E_BASE_URL=base_url, + TURNIERE_E2E_EMAIL=email, + TURNIERE_E2E_PASSWORD=password, + TURNIERE_E2E_USERNAME=username, + ), + pty=True, + ) + finally: + _stop_process(process, log_handle) + + @task(name="blackbox-production") def blackbox_production( c, From 96e80b33755c0b5d24894abf8288a463aa38e4b3 Mon Sep 17 00:00:00 2001 From: Malaber Date: Sun, 12 Apr 2026 09:34:05 +0200 Subject: [PATCH 16/18] Use per-job network for blackbox services --- .gitlab-ci.yml | 23 ++++++++++++++++++----- AGENTS.md | 2 +- 2 files changed, 19 insertions(+), 6 deletions(-) diff --git a/.gitlab-ci.yml b/.gitlab-ci.yml index feaf8dd..bb90d1c 100644 --- a/.gitlab-ci.yml +++ b/.gitlab-ci.yml @@ -35,11 +35,24 @@ rails spec: rails e2e spec: stage: test - image: $CI_REGISTRY_IMAGE/production/commits:$CI_COMMIT_SHA + image: $CI_REGISTRY_IMAGE/test/commits:$CI_COMMIT_SHA services: - name: postgres:16 alias: postgres + - name: $CI_REGISTRY_IMAGE/production/commits:$CI_COMMIT_SHA + alias: app + command: + - bundle + - exec + - rails + - s + - -p + - "3000" + - -b + - 0.0.0.0 variables: + FF_NETWORK_PER_BUILD: "true" + CI_DEBUG_SERVICES: "true" POSTGRES_DB: turniere_blackbox POSTGRES_USER: turniere POSTGRES_PASSWORD: turniere @@ -50,9 +63,9 @@ rails e2e spec: MAILGUN_DOMAIN: blackbox.example.com RAILS_LOG_TO_STDOUT: "1" RAILS_SERVE_STATIC_FILES: "1" - NO_PROXY: postgres,localhost,127.0.0.1 - no_proxy: postgres,localhost,127.0.0.1 - TURNIERE_E2E_BASE_URL: http://127.0.0.1:3000 + NO_PROXY: app,postgres,localhost,127.0.0.1 + no_proxy: app,postgres,localhost,127.0.0.1 + TURNIERE_E2E_BASE_URL: http://app:3000 TURNIERE_E2E_EMAIL: e2e@example.com TURNIERE_E2E_PASSWORD: password123 TURNIERE_E2E_USERNAME: e2e-user @@ -68,4 +81,4 @@ rails e2e spec: - when: always script: - cd /app - - inv blackbox-self + - inv blackbox-service diff --git a/AGENTS.md b/AGENTS.md index a849811..4054ede 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -24,7 +24,7 @@ Examples: - `inv docker-test-http-e2e` The GitLab CI pipeline is expected to use these tasks as well. -In CI, blackbox E2E should run from the production image with Postgres as the sidecar and should avoid Docker Compose inside the job. +In CI, blackbox E2E should prefer the documented GitLab service-to-service model with `FF_NETWORK_PER_BUILD: "true"` when the production image needs to talk to a Postgres sidecar. The normal Rails spec suite is sharded through `inv test-shard ...` and should stay aligned with the CI matrix configuration. Useful GitLab push-option variables for saving CI minutes on public repos: From 012523ce6c38b486887f9f1ebfebe2ce5bd593e5 Mon Sep 17 00:00:00 2001 From: Malaber Date: Sun, 12 Apr 2026 09:43:57 +0200 Subject: [PATCH 17/18] Remove temporary blackbox fallback tooling --- .gitlab-ci.yml | 1 - AGENTS.md | 1 - script/verify_e2e_blackbox.rb | 65 ----------------------------------- tasks.py | 42 ---------------------- 4 files changed, 109 deletions(-) delete mode 100644 script/verify_e2e_blackbox.rb diff --git a/.gitlab-ci.yml b/.gitlab-ci.yml index bb90d1c..8dc62f8 100644 --- a/.gitlab-ci.yml +++ b/.gitlab-ci.yml @@ -52,7 +52,6 @@ rails e2e spec: - 0.0.0.0 variables: FF_NETWORK_PER_BUILD: "true" - CI_DEBUG_SERVICES: "true" POSTGRES_DB: turniere_blackbox POSTGRES_USER: turniere POSTGRES_PASSWORD: turniere diff --git a/AGENTS.md b/AGENTS.md index 4054ede..986bad6 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -16,7 +16,6 @@ Examples: - `inv test-shard --node-index=1 --node-total=8` - `inv lint` - `inv verify-http` -- `inv blackbox-self` - `inv blackbox-production` - `inv scenario-main-usecase` - `inv docker-build-all` diff --git a/script/verify_e2e_blackbox.rb b/script/verify_e2e_blackbox.rb deleted file mode 100644 index acf91bf..0000000 --- a/script/verify_e2e_blackbox.rb +++ /dev/null @@ -1,65 +0,0 @@ -#!/usr/bin/env ruby -# frozen_string_literal: true - -require_relative '../e2e/lib/scenario_runner' - -def assert_equal(expected, actual, label) - return if expected == actual - - raise "#{label}: expected #{expected.inspect}, got #{actual.inspect}" -end - -def assert(condition, label) - return if condition - - raise label -end - -runner = TurniereE2E::ScenarioRunner.new( - base_url: ENV.fetch('TURNIERE_E2E_BASE_URL', 'http://127.0.0.1:3000'), - email: ENV.fetch('TURNIERE_E2E_EMAIL'), - password: ENV.fetch('TURNIERE_E2E_PASSWORD'), - username: ENV['TURNIERE_E2E_USERNAME'] -) - -puts 'Verifying main 4x4 group-stage lifecycle...' -main_result = runner.run_group_stage_main_usecase -assert_equal(8, main_result.dig(:checkpoints, :created, :playoff_teams_amount), 'main playoff_teams_amount') -assert_equal(8, main_result.dig(:checkpoints, :created, :instant_finalists_amount), 'main instant_finalists_amount') -assert_equal(0, main_result.dig(:checkpoints, :created, :intermediate_round_participants_amount), 'main intermediate_round_participants_amount') -assert_equal(4, main_result.dig(:checkpoints, :upcoming_group_matches, :match_ids).count, 'main upcoming_group_matches count') -assert_equal(16, main_result.dig(:checkpoints, :statistics, :group_scores_count), 'main group_scores_count') -main_playoff_entry_stage = main_result.dig(:checkpoints, :playoffs_created, :stage_summaries) - .select { |stage| stage[:level] != -1 } - .max_by { |stage| stage[:level] } -assert_equal(4, main_playoff_entry_stage[:match_states].count, 'main playoff entry match count') -main_final_attempt = main_result.dig(:checkpoints, :final_finish_attempted, :finish_attempt) -assert_equal(422, main_final_attempt[:status], 'main final finish status') -assert_equal('Moving Team one stage down failed', main_final_attempt.dig(:body, :error), 'main final finish error') - -puts 'Verifying 3-group intermediate-round lifecycle...' -intermediate_result = runner.run_group_stage_intermediate_round -intermediate_created = intermediate_result.fetch(:checkpoints).fetch(:created) -assert_equal(4, intermediate_created[:playoff_teams_amount], 'intermediate playoff_teams_amount') -assert_equal(3, intermediate_created[:instant_finalists_amount], 'intermediate instant_finalists_amount') -assert_equal(2, intermediate_created[:intermediate_round_participants_amount], 'intermediate intermediate_round_participants_amount') -intermediate_playoff_entry_stage = intermediate_result.dig(:checkpoints, :playoffs_created, :stage_summaries) - .select { |stage| stage[:level] != -1 } - .max_by { |stage| stage[:level] } -assert_equal('intermediate_stage', intermediate_playoff_entry_stage[:state], 'intermediate stage state') -assert_equal(3, intermediate_playoff_entry_stage[:match_states].count('single_team'), 'intermediate single_team count') -assert_equal(1, intermediate_playoff_entry_stage[:match_states].count('not_started'), 'intermediate not_started count') -intermediate_final_attempt = intermediate_result.dig(:checkpoints, :final_finish_attempted, :finish_attempt) -assert_equal(422, intermediate_final_attempt[:status], 'intermediate final finish status') -assert_equal('Moving Team one stage down failed', intermediate_final_attempt.dig(:body, :error), 'intermediate final finish error') - -[2, 3, 5, 8].each do |team_count| - puts "Verifying playoff-only lifecycle for #{team_count} teams..." - playoff_result = runner.run_playoff_only(team_count: team_count) - final_stage = playoff_result.dig(:checkpoints, :completed, :stage_summaries) - .find { |stage| stage[:level] == 0 } - assert(final_stage, "missing final stage for playoff-only #{team_count}") - assert_equal(['finished'], final_stage[:match_states], "playoff-only #{team_count} final stage") -end - -puts 'Blackbox verification passed.' diff --git a/tasks.py b/tasks.py index 45926f3..e038edb 100644 --- a/tasks.py +++ b/tasks.py @@ -653,48 +653,6 @@ def blackbox_service( _run_blackbox_rspec(base_url, email, password, username) -@task(name="blackbox-self") -def blackbox_self( - c, - base_url=TEST_BASE_URL, - email=E2E_EMAIL, - password=E2E_PASSWORD, - username=E2E_USERNAME, -): - """Run the pure-Ruby blackbox verifier inside the production image against a local production server.""" - env = _shared_production_env() - server_command = f"bundle exec rails s -b {SERVER_HOST} -p {SERVER_PORT}" - - _print_header("Running production database migrations") - c.run("bundle exec rails db:migrate", env=env, pty=True) - - process, log_handle = _start_server(server_command, env) - try: - _print_header(f"Waiting for app healthcheck at {base_url}") - _wait_for_http(base_url=base_url, timeout=120) - - _print_header("Bootstrapping confirmed E2E user") - c.run( - _bootstrap_user_command(email, password, username), - env=env, - pty=True, - ) - - _print_header("Running blackbox verifier") - c.run( - "ruby script/verify_e2e_blackbox.rb", - env=_env( - TURNIERE_E2E_BASE_URL=base_url, - TURNIERE_E2E_EMAIL=email, - TURNIERE_E2E_PASSWORD=password, - TURNIERE_E2E_USERNAME=username, - ), - pty=True, - ) - finally: - _stop_process(process, log_handle) - - @task(name="blackbox-production") def blackbox_production( c, From 0cf635d1ec9b93c4c9ba3bdc42c573bacc7b3965 Mon Sep 17 00:00:00 2001 From: Malaber Date: Sun, 12 Apr 2026 09:43:57 +0200 Subject: [PATCH 18/18] Switch invoke to dev image --- docker/development/Dockerfile | 1 + docker/production/Dockerfile | 2 +- 2 files changed, 2 insertions(+), 1 deletion(-) diff --git a/docker/development/Dockerfile b/docker/development/Dockerfile index 112d220..787544f 100644 --- a/docker/development/Dockerfile +++ b/docker/development/Dockerfile @@ -2,6 +2,7 @@ ARG base_commit FROM registry.gitlab.com/turniere/turniere-backend/production/commits:$base_commit WORKDIR /app +RUN apt-get update -qq && apt-get install -y python3 python3-pip && apt-get clean && python3 -m pip install --no-cache-dir invoke RUN bundle config set with 'development' && bundle install ENV RAILS_ENV development CMD bundle exec rails db:migrate && bundle exec rails s -p 3000 -b 0.0.0.0 diff --git a/docker/production/Dockerfile b/docker/production/Dockerfile index a49494d..2740262 100644 --- a/docker/production/Dockerfile +++ b/docker/production/Dockerfile @@ -1,7 +1,7 @@ ARG base_commit FROM ruby:3.1.2-slim # RUN apk add --no-cache build-base tzdata sqlite sqlite-dev postgresql-dev git && gem install tzinfo-data -RUN apt-get update -qq && apt-get install -y openssl git build-essential libpq-dev nodejs python3 python3-pip && apt-get clean && gem install tzinfo-data && python3 -m pip install --no-cache-dir invoke +RUN apt-get update -qq && apt-get install -y openssl git build-essential libpq-dev nodejs && apt-get clean && gem install tzinfo-data WORKDIR /app COPY Gemfile* /app/ RUN gem install bundler:2.3.13 && bundle config set deployment 'true' && bundle install