Merge branch 'codex/backend-e2e-tests' into 'master'

Add backend lifecycle E2E coverage

See merge request turniere/turniere-backend!31
This commit is contained in:
Daniel Schädler 2026-04-12 08:13:19 +00:00
commit 2bff3613d0
13 changed files with 1642 additions and 3 deletions

3
.gitignore vendored
View File

@ -34,4 +34,5 @@ coverage/**
/config/credentials/beta.key
/specs_with_runtime.txt
/specs_with_runtime.txt
.venv/

View File

@ -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

84
AGENTS.md Normal file
View File

@ -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.

View File

@ -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:

View File

@ -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

View File

@ -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

123
e2e/lib/api_client.rb Normal file
View File

@ -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

390
e2e/lib/scenario_runner.rb Normal file
View File

@ -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

47
script/e2e_scenarios.rb Normal file
View File

@ -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)

11
script/wait_for_http.rb Normal file
View File

@ -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}"

View File

@ -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

View File

@ -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.

761
tasks.py Normal file
View File

@ -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 "<no logs>"
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)