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