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 3b5f8bf..8dc62f8 100644 --- a/.gitlab-ci.yml +++ b/.gitlab-ci.yml @@ -10,17 +10,74 @@ 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 + - if: $SKIP_ALL_SPECS + when: never + - if: $SKIP_RAILS_SPEC + when: never - if: $CI_PIPELINE_SOURCE != "push" when: never - when: always script: - cd /app - - bundle exec rails db:migrate - - bundle exec rspec + - inv db-migrate + - inv test-shard --node-index=$RSPEC_NODE_INDEX --node-total=$RSPEC_NODE_TOTAL artifacts: paths: - specs_with_runtime.txt expire_in: 30 days + +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 + command: + - bundle + - exec + - rails + - s + - -p + - "3000" + - -b + - 0.0.0.0 + variables: + FF_NETWORK_PER_BUILD: "true" + 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" + 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 + rules: + - if: $SKIP_TEST + when: never + - if: $SKIP_ALL_SPECS + when: never + - if: $SKIP_E2E_SPEC + when: never + - if: $CI_PIPELINE_SOURCE != "push" + when: never + - when: always + script: + - cd /app + - inv blackbox-service diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 0000000..986bad6 --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,84 @@ +# 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 test-shard --node-index=1 --node-total=8` +- `inv lint` +- `inv verify-http` +- `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. +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: + +- 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. + +## 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 +- the released production image should be exercised through the blackbox task layer, not only Rails-internal test entrypoints + +## Practical Expectation + +If you touch test, verification, boot, Docker, or scenario setup workflows, +check whether `tasks.py` also needs to change. 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/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 42a6684..2740262 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 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..a65b492 --- /dev/null +++ b/spec/e2e/http/tournament_lifecycle_spec.rb @@ -0,0 +1,71 @@ +# frozen_string_literal: true + +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( + 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 new file mode 100644 index 0000000..fccc3d4 --- /dev/null +++ b/spec/e2e_known_behaviors.md @@ -0,0 +1,46 @@ +# Backend E2E Known Behaviors + +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/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: + - 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/e2e/http/tournament_lifecycle_spec.rb` and `script/e2e_scenarios.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/tasks.py b/tasks.py new file mode 100644 index 0000000..e038edb --- /dev/null +++ b/tasks.py @@ -0,0 +1,761 @@ +from invoke import task + +import os +import re +import shlex +import signal +import subprocess +import time +import urllib.request +import urllib.error +from pathlib import Path + + +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" +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" +SPEC_ROOT = Path("spec") +E2E_SPEC_ROOT = SPEC_ROOT / "e2e" / "http" + + +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" + 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 + 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) + + 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): + 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 _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", + 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_server(command, env, log_path="/tmp/turniere-e2e-server.log"): + log_handle = open(log_path, "w") + process = subprocess.Popen( + ["/bin/sh", "-lc", command], + env=env, + stdout=log_handle, + stderr=subprocess.STDOUT, + preexec_fn=os.setsid, + ) + 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) + 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(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 --require ./spec/rails_helper " + " ".join(shlex.quote(path) for path in files), + 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-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("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) + + _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, + 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) + + +@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)