Merge branch 'master' into TUR-32_backend-beamer
This commit is contained in:
commit
2ceba1dfcf
|
|
@ -1,10 +1,12 @@
|
||||||
.git
|
.git
|
||||||
tmp/**
|
tmp/**
|
||||||
coverage/**
|
coverage/**
|
||||||
|
db/*.sqlite3
|
||||||
|
db/*.sqlite3-*
|
||||||
|
db/*.sqlite3.*
|
||||||
docker/**
|
docker/**
|
||||||
.gitignore
|
.gitignore
|
||||||
.hound.yml
|
.hound.yml
|
||||||
.rspec
|
|
||||||
.rubocop.yml
|
.rubocop.yml
|
||||||
docker-compose.yml
|
docker-compose.yml
|
||||||
README.md
|
README.md
|
||||||
|
|
|
||||||
|
|
@ -3,12 +3,17 @@ stages:
|
||||||
- test
|
- test
|
||||||
- deploy
|
- deploy
|
||||||
|
|
||||||
|
variables:
|
||||||
|
DOCKER_IMAGE_ENVS: "production test"
|
||||||
|
|
||||||
include:
|
include:
|
||||||
- project: 'turniere/turniere-infra'
|
- project: 'turniere/turniere-infra'
|
||||||
file: '/ci/pipeline.yaml'
|
file: '/ci/pipeline.yaml'
|
||||||
|
|
||||||
rails spec:
|
rails spec:
|
||||||
stage: test
|
stage: test
|
||||||
|
tags:
|
||||||
|
- docker
|
||||||
image: $CI_REGISTRY_IMAGE/test/commits:$CI_COMMIT_SHA
|
image: $CI_REGISTRY_IMAGE/test/commits:$CI_COMMIT_SHA
|
||||||
parallel:
|
parallel:
|
||||||
matrix:
|
matrix:
|
||||||
|
|
@ -35,6 +40,8 @@ rails spec:
|
||||||
|
|
||||||
rails e2e spec:
|
rails e2e spec:
|
||||||
stage: test
|
stage: test
|
||||||
|
tags:
|
||||||
|
- docker
|
||||||
image: $CI_REGISTRY_IMAGE/test/commits:$CI_COMMIT_SHA
|
image: $CI_REGISTRY_IMAGE/test/commits:$CI_COMMIT_SHA
|
||||||
services:
|
services:
|
||||||
- name: postgres:16
|
- name: postgres:16
|
||||||
|
|
|
||||||
31
AGENTS.md
31
AGENTS.md
|
|
@ -30,10 +30,12 @@ For local development, the preferred path is:
|
||||||
Useful local commands:
|
Useful local commands:
|
||||||
|
|
||||||
- `inv setup`
|
- `inv setup`
|
||||||
|
- `inv db-reset --env=test`
|
||||||
- `inv db-migrate`
|
- `inv db-migrate`
|
||||||
- `inv server`
|
- `inv server`
|
||||||
- `inv start-local`
|
- `inv start-local`
|
||||||
- `inv start-local --bootstrap-user`
|
- `inv start-local --bootstrap-user`
|
||||||
|
- `inv start-test-local`
|
||||||
- `inv bootstrap-dev-user`
|
- `inv bootstrap-dev-user`
|
||||||
- `inv bootstrap-e2e-user`
|
- `inv bootstrap-e2e-user`
|
||||||
|
|
||||||
|
|
@ -53,6 +55,11 @@ Examples:
|
||||||
For local E2E-style startup with a usable login, prefer:
|
For local E2E-style startup with a usable login, prefer:
|
||||||
|
|
||||||
- `inv start-local --bootstrap-user`
|
- `inv start-local --bootstrap-user`
|
||||||
|
- `inv start-test-local`
|
||||||
|
|
||||||
|
`inv start-test-local` is preferred for cross-repo or Playwright-style local E2E runs.
|
||||||
|
It recreates `db/test.sqlite3` on every run, re-migrates, bootstraps the default E2E
|
||||||
|
users, and starts Rails in `test` mode on `0.0.0.0:3000`.
|
||||||
|
|
||||||
Default local bootstrap credentials:
|
Default local bootstrap credentials:
|
||||||
|
|
||||||
|
|
@ -105,6 +112,30 @@ 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,
|
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.
|
or scenario workflow, add or extend a task for it as part of the same change.
|
||||||
|
|
||||||
|
## Local-Only Helper Code
|
||||||
|
|
||||||
|
If helper code exists only for local development, local profiling, or test/E2E support,
|
||||||
|
prefer placing it in a path that the production Docker image does not copy.
|
||||||
|
|
||||||
|
Current production image copies:
|
||||||
|
|
||||||
|
- `app`
|
||||||
|
- `bin`
|
||||||
|
- `config`
|
||||||
|
- `db`
|
||||||
|
- `public`
|
||||||
|
- `script`
|
||||||
|
- `config.ru`
|
||||||
|
- `Rakefile`
|
||||||
|
|
||||||
|
It does not copy `lib`, `spec`, `e2e`, or `tasks.py`.
|
||||||
|
|
||||||
|
Practical rule:
|
||||||
|
|
||||||
|
- production runtime code belongs in copied paths such as `app/`
|
||||||
|
- local-only helpers should prefer `lib/`, `spec/`, `e2e/`, or task-layer code when feasible
|
||||||
|
- if production must ignore a local-only feature, cover that with blackbox E2E against the production image
|
||||||
|
|
||||||
## HTTP E2E
|
## HTTP E2E
|
||||||
|
|
||||||
The backend HTTP E2E flow is intended to be reusable outside this repo, especially by frontend tests that need realistic backend state.
|
The backend HTTP E2E flow is intended to be reusable outside this repo, especially by frontend tests that need realistic backend state.
|
||||||
|
|
|
||||||
12
README.md
12
README.md
|
|
@ -31,22 +31,20 @@ bundle exec rspec
|
||||||
[Registry](https://gitlab.com/turniere/turniere-backend/container_registry)
|
[Registry](https://gitlab.com/turniere/turniere-backend/container_registry)
|
||||||
|
|
||||||
You can find all our Dockerfiles in the docker directory.
|
You can find all our Dockerfiles in the docker directory.
|
||||||
They depend on each other in the following order: `production` → `development` → `test`
|
They depend on each other in the following order: `production` → `test`
|
||||||
This means, to build the `development` image, you have to build the `production` image first and tag it with the corresponding tag that is mentioned in the `FROM` line in the `development` Dockerfile.
|
This means, to build `test` image, you have to build `production` image first and tag it with corresponding tag that is mentioned in `FROM` line in `test` Dockerfile.
|
||||||
To build all images do:
|
To build all images do:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
cd turniere-backend
|
cd turniere-backend
|
||||||
docker build -t registry.gitlab.com/turniere/turniere-backend/production -f docker/production/Dockerfile .
|
docker build -t registry.gitlab.com/turniere/turniere-backend/production -f docker/production/Dockerfile .
|
||||||
docker build -t registry.gitlab.com/turniere/turniere-backend/development -f docker/development/Dockerfile .
|
|
||||||
docker build -t registry.gitlab.com/turniere/turniere-backend/test -f docker/test/Dockerfile .
|
docker build -t registry.gitlab.com/turniere/turniere-backend/test -f docker/test/Dockerfile .
|
||||||
```
|
```
|
||||||
|
|
||||||
This is done to leave test and development dependencies out of the production container.
|
This is done to leave test and development dependencies out of production container.
|
||||||
Also we have a dedicated test container which runs the tests reproducible when you start it, but can also run the normal rails server to somewhat debug problems occuring in the test suite if needed.
|
Also we have dedicated test container which runs tests reproducible when you start it, but can also run normal rails server to debug problems occuring in test suite if needed.
|
||||||
|
|
||||||
While developing, if you want to use the development docker container, it should™ be sufficient to mount the root of this repository into the /app folder within the docker container to avoid building it over and over again.
|
While developing, use `test` image as base container and mount root of this repository into `/app` to avoid rebuilding it over and over again.
|
||||||
**Only rebuilding the `development` container is not sufficient, as the `development` Dockerfile does not have a `COPY` Statement**
|
|
||||||
|
|
||||||
|
|
||||||
## Generate diagrams
|
## Generate diagrams
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,18 @@
|
||||||
# frozen_string_literal: true
|
# frozen_string_literal: true
|
||||||
|
|
||||||
class TournamentsController < ApplicationController
|
class TournamentsController < ApplicationController
|
||||||
before_action :set_tournament, only: %i[show update destroy set_timer_end timer_end]
|
class NoOpRequestProfiling
|
||||||
|
def measure(_name)
|
||||||
|
yield
|
||||||
|
end
|
||||||
|
|
||||||
|
def apply_to(_response, label:)
|
||||||
|
label
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
before_action :set_tournament_for_show, only: %i[show]
|
||||||
|
before_action :set_tournament, only: %i[update destroy set_timer_end timer_end]
|
||||||
before_action :authenticate_user!, only: %i[create update destroy set_timer_end]
|
before_action :authenticate_user!, only: %i[create update destroy set_timer_end]
|
||||||
before_action -> { require_owner! @tournament.owner }, only: %i[update destroy set_timer_end]
|
before_action -> { require_owner! @tournament.owner }, only: %i[update destroy set_timer_end]
|
||||||
before_action :validate_create_params, only: %i[create]
|
before_action :validate_create_params, only: %i[create]
|
||||||
|
|
@ -30,7 +41,11 @@ class TournamentsController < ApplicationController
|
||||||
if show_params.fetch(:simple, 'false') == 'true'
|
if show_params.fetch(:simple, 'false') == 'true'
|
||||||
render json: @tournament, serializer: SimpleTournamentSerializer
|
render json: @tournament, serializer: SimpleTournamentSerializer
|
||||||
else
|
else
|
||||||
render json: @tournament, include: '**'
|
rendered_json = @request_profiling.measure('serialize_tournament') do
|
||||||
|
ActiveModelSerializers::SerializableResource.new(@tournament, include: '**').as_json
|
||||||
|
end
|
||||||
|
@request_profiling.apply_to(response, label: 'tournament.show')
|
||||||
|
render json: rendered_json
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
|
|
@ -141,12 +156,41 @@ class TournamentsController < ApplicationController
|
||||||
@tournament = Tournament.find(params[:id])
|
@tournament = Tournament.find(params[:id])
|
||||||
end
|
end
|
||||||
|
|
||||||
|
def set_tournament_for_show
|
||||||
|
profiling = build_request_profiling
|
||||||
|
@tournament = profiling.measure('load_tournament') do
|
||||||
|
Tournament.includes(
|
||||||
|
:user,
|
||||||
|
:teams,
|
||||||
|
stages: [
|
||||||
|
{ matches: { match_scores: :team } },
|
||||||
|
{ groups: [
|
||||||
|
{ matches: { match_scores: :team } },
|
||||||
|
{ group_scores: :team }
|
||||||
|
] }
|
||||||
|
]
|
||||||
|
).find(params[:id])
|
||||||
|
end
|
||||||
|
@request_profiling = profiling
|
||||||
|
end
|
||||||
|
|
||||||
def index_params
|
def index_params
|
||||||
params.permit(:type)
|
params.permit(:type)
|
||||||
end
|
end
|
||||||
|
|
||||||
def show_params
|
def show_params
|
||||||
params.permit(:simple)
|
params.permit(:simple, :profile)
|
||||||
|
end
|
||||||
|
|
||||||
|
def profiling_requested?
|
||||||
|
!Rails.env.production? && show_params.fetch(:profile, 'false') == 'true'
|
||||||
|
end
|
||||||
|
|
||||||
|
def build_request_profiling
|
||||||
|
return NoOpRequestProfiling.new unless profiling_requested?
|
||||||
|
|
||||||
|
require Rails.root.join('lib/local/request_profiling')
|
||||||
|
Local::RequestProfiling.new(enabled: true)
|
||||||
end
|
end
|
||||||
|
|
||||||
def tournament_params
|
def tournament_params
|
||||||
|
|
|
||||||
|
|
@ -114,10 +114,28 @@ class GroupScore < ApplicationRecord
|
||||||
end
|
end
|
||||||
|
|
||||||
def comparison_match_against(other, decider:)
|
def comparison_match_against(other, decider:)
|
||||||
group.matches.reload
|
comparison_matches_index[[decider, *[team.id, other.team.id].sort]]
|
||||||
.select do |match|
|
end
|
||||||
match.decider? == decider && match.teams.include?(team) && match.teams.include?(other.team)
|
|
||||||
end
|
def comparison_matches_index
|
||||||
.max_by(&:position)
|
cache = group.instance_variable_get(:@comparison_matches_index)
|
||||||
|
return cache unless cache.nil?
|
||||||
|
|
||||||
|
matches = if group.association(:matches).loaded? && group.matches.any?
|
||||||
|
group.matches
|
||||||
|
else
|
||||||
|
group.matches.includes(match_scores: :team).reload
|
||||||
|
end
|
||||||
|
|
||||||
|
cache = matches.each_with_object({}) do |match, index|
|
||||||
|
team_ids = match.teams.map(&:id).sort
|
||||||
|
next unless team_ids.size == 2
|
||||||
|
|
||||||
|
key = [match.decider?, *team_ids]
|
||||||
|
existing_match = index[key]
|
||||||
|
index[key] = match if existing_match.nil? || match.position.to_i > existing_match.position.to_i
|
||||||
|
end
|
||||||
|
|
||||||
|
group.instance_variable_set(:@comparison_matches_index, cache)
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
|
||||||
|
|
@ -41,14 +41,18 @@ class Match < ApplicationRecord
|
||||||
|
|
||||||
def scored_points_of(team)
|
def scored_points_of(team)
|
||||||
return 0 if decider?
|
return 0 if decider?
|
||||||
|
return 0 unless (finished? || in_progress?) && teams.include?(team)
|
||||||
|
|
||||||
teams.include?(team) ? match_scores.find_by(team: team).points : 0
|
match_score = match_score_for(team)
|
||||||
|
match_score ? match_score.points : 0
|
||||||
end
|
end
|
||||||
|
|
||||||
def received_points_of(team)
|
def received_points_of(team)
|
||||||
return 0 if decider?
|
return 0 if decider?
|
||||||
|
return 0 unless (finished? || in_progress?) && teams.include?(team)
|
||||||
|
|
||||||
teams.include?(team) ? match_scores.find { |ms| ms.team != team }.points : 0
|
opponent_match_score = opponent_match_score_for(team)
|
||||||
|
opponent_match_score ? opponent_match_score.points : 0
|
||||||
end
|
end
|
||||||
|
|
||||||
def group_points_of(team)
|
def group_points_of(team)
|
||||||
|
|
@ -66,11 +70,20 @@ class Match < ApplicationRecord
|
||||||
end
|
end
|
||||||
|
|
||||||
def hidden_points_of(team)
|
def hidden_points_of(team)
|
||||||
teams.include?(team) ? match_scores.find_by(team: team).hidden_points : 0
|
match_score = match_score_for(team)
|
||||||
|
match_score ? match_score.hidden_points : 0
|
||||||
end
|
end
|
||||||
|
|
||||||
private
|
private
|
||||||
|
|
||||||
|
def match_score_for(team)
|
||||||
|
match_scores.find { |match_score| match_score.team_id == team.id }
|
||||||
|
end
|
||||||
|
|
||||||
|
def opponent_match_score_for(team)
|
||||||
|
match_scores.find { |match_score| match_score.team_id != team.id }
|
||||||
|
end
|
||||||
|
|
||||||
def stage_xor_group
|
def stage_xor_group
|
||||||
errors.add(:stage_xor_group, 'Stage and Group missing or both present') unless stage.present? ^ group.present?
|
errors.add(:stage_xor_group, 'Stage and Group missing or both present') unless stage.present? ^ group.present?
|
||||||
end
|
end
|
||||||
|
|
|
||||||
|
|
@ -30,7 +30,7 @@ class Tournament < ApplicationRecord
|
||||||
|
|
||||||
def group_stage
|
def group_stage
|
||||||
# get the stage with level -1 (group stage)
|
# get the stage with level -1 (group stage)
|
||||||
stages.find_by(level: -1)
|
stages.find { |stage| stage.level == -1 }
|
||||||
end
|
end
|
||||||
|
|
||||||
private
|
private
|
||||||
|
|
|
||||||
|
|
@ -43,7 +43,7 @@ class GroupStageService
|
||||||
#
|
#
|
||||||
# This problem is only fixed for a group size of 4, because we did not come up with a generalized version of this
|
# This problem is only fixed for a group size of 4, because we did not come up with a generalized version of this
|
||||||
# switcharoo magic and we needed it for groups of 4.
|
# switcharoo magic and we needed it for groups of 4.
|
||||||
return unless team_size == 4
|
return matches unless team_size == 4
|
||||||
|
|
||||||
matches[5].position, matches[1].position = matches[1].position, matches[5].position
|
matches[5].position, matches[1].position = matches[1].position, matches[5].position
|
||||||
matches
|
matches
|
||||||
|
|
@ -97,37 +97,15 @@ class GroupStageService
|
||||||
end
|
end
|
||||||
|
|
||||||
def ranking_decision_for(group_score)
|
def ranking_decision_for(group_score)
|
||||||
sorted_group_scores = group_score.group.group_scores.reload.sort
|
ranking_decisions_for_group(group_score.group)[group_score_cache_key(group_score)]
|
||||||
index = sorted_group_scores.index(group_score)
|
|
||||||
return nil if index.nil? || sorted_group_scores.size <= 1
|
|
||||||
|
|
||||||
comparison_index = comparison_partner_index(sorted_group_scores, index)
|
|
||||||
return nil if comparison_index.nil?
|
|
||||||
|
|
||||||
compared_group_score = sorted_group_scores[comparison_index]
|
|
||||||
comparison_reason = group_score.comparison_reason_against(compared_group_score)
|
|
||||||
|
|
||||||
{
|
|
||||||
resolved_by: comparison_reason[:resolved_by].to_s,
|
|
||||||
compared_with_team_id: compared_group_score.team.id,
|
|
||||||
compared_with_team_name: compared_group_score.team.name,
|
|
||||||
direct_comparison_result: comparison_reason[:direct_comparison_result].to_s,
|
|
||||||
direct_comparison_match_id: comparison_reason[:direct_comparison_match_id],
|
|
||||||
hidden_points_result: comparison_reason[:hidden_points_result].to_s,
|
|
||||||
decider_match_id: comparison_reason[:decider_match_id],
|
|
||||||
tied: comparison_reason[:tied],
|
|
||||||
needs_decider_match: comparison_reason[:tied] &&
|
|
||||||
unresolved_tie_crosses_advancing_cutoff?(sorted_group_scores, group_score, advancing_slots_for_group(group_score.group))
|
|
||||||
}
|
|
||||||
end
|
end
|
||||||
|
|
||||||
def blocking_ties_for(group_stage)
|
def blocking_ties_for(group_stage)
|
||||||
return [] if group_stage.nil?
|
return [] if group_stage.nil?
|
||||||
|
|
||||||
group_stage.groups.flat_map do |group|
|
group_stage.groups.flat_map do |group|
|
||||||
sorted_scores = group.group_scores.reload.sort
|
ranking_decisions_for_group(group).filter_map do |group_score_id, decision|
|
||||||
sorted_scores.filter_map do |group_score|
|
group_score = group_scores_for(group).find { |score| group_score_cache_key(score) == group_score_id }
|
||||||
decision = ranking_decision_for(group_score)
|
|
||||||
next unless decision&.fetch(:needs_decider_match, false)
|
next unless decision&.fetch(:needs_decider_match, false)
|
||||||
|
|
||||||
comparison_team_id = decision[:compared_with_team_id]
|
comparison_team_id = decision[:compared_with_team_id]
|
||||||
|
|
@ -286,5 +264,68 @@ class GroupStageService
|
||||||
base_slots + (group_index < extra_slot_groups ? 1 : 0)
|
base_slots + (group_index < extra_slot_groups ? 1 : 0)
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
|
def ranking_decisions_for_group(group)
|
||||||
|
cache = group.instance_variable_get(:@ranking_decisions_cache)
|
||||||
|
return cache unless cache.nil?
|
||||||
|
|
||||||
|
sorted_group_scores = group_scores_for(group).sort
|
||||||
|
advancing_slots = advancing_slots_for_group(group)
|
||||||
|
group_ready_for_decider_check = regular_group_matches_finished?(group)
|
||||||
|
|
||||||
|
cache = sorted_group_scores.each_with_index.each_with_object({}) do |(group_score, index), decisions|
|
||||||
|
next if sorted_group_scores.size <= 1
|
||||||
|
|
||||||
|
comparison_index = comparison_partner_index(sorted_group_scores, index)
|
||||||
|
next if comparison_index.nil?
|
||||||
|
|
||||||
|
compared_group_score = sorted_group_scores[comparison_index]
|
||||||
|
comparison_reason = group_score.comparison_reason_against(compared_group_score)
|
||||||
|
|
||||||
|
if comparison_reason[:tied] && !group_ready_for_decider_check
|
||||||
|
decisions[group_score_cache_key(group_score)] = nil
|
||||||
|
next
|
||||||
|
end
|
||||||
|
|
||||||
|
decisions[group_score_cache_key(group_score)] = {
|
||||||
|
resolved_by: comparison_reason[:resolved_by].to_s,
|
||||||
|
compared_with_team_id: compared_group_score.team.id,
|
||||||
|
compared_with_team_name: compared_group_score.team.name,
|
||||||
|
direct_comparison_result: comparison_reason[:direct_comparison_result].to_s,
|
||||||
|
direct_comparison_match_id: comparison_reason[:direct_comparison_match_id],
|
||||||
|
hidden_points_result: comparison_reason[:hidden_points_result].to_s,
|
||||||
|
decider_match_id: comparison_reason[:decider_match_id],
|
||||||
|
tied: comparison_reason[:tied],
|
||||||
|
needs_decider_match: group_ready_for_decider_check &&
|
||||||
|
comparison_reason[:tied] &&
|
||||||
|
unresolved_tie_crosses_advancing_cutoff?(sorted_group_scores, group_score, advancing_slots)
|
||||||
|
}
|
||||||
|
end
|
||||||
|
|
||||||
|
group.instance_variable_set(:@ranking_decisions_cache, cache)
|
||||||
|
end
|
||||||
|
|
||||||
|
def group_scores_for(group)
|
||||||
|
if group.association(:group_scores).loaded? && group.group_scores.any?
|
||||||
|
group.group_scores
|
||||||
|
else
|
||||||
|
group.group_scores.includes(:team).reload
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
def group_score_cache_key(group_score)
|
||||||
|
group_score.id || group_score.object_id
|
||||||
|
end
|
||||||
|
|
||||||
|
def regular_group_matches_finished?(group)
|
||||||
|
matches = if group.association(:matches).loaded? && group.matches.any?
|
||||||
|
group.matches
|
||||||
|
else
|
||||||
|
group.matches.reload
|
||||||
|
end
|
||||||
|
|
||||||
|
regular_matches = matches.reject(&:decider?)
|
||||||
|
regular_matches.any? && regular_matches.all?(&:finished?)
|
||||||
|
end
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
|
||||||
|
|
@ -38,9 +38,13 @@ services:
|
||||||
working_dir: /app
|
working_dir: /app
|
||||||
environment:
|
environment:
|
||||||
TURNIERE_E2E_BASE_URL: ${TURNIERE_E2E_BASE_URL:-http://app:3000}
|
TURNIERE_E2E_BASE_URL: ${TURNIERE_E2E_BASE_URL:-http://app:3000}
|
||||||
|
TURNIERE_E2E_EXPECT_PROFILING: ${TURNIERE_E2E_EXPECT_PROFILING:-true}
|
||||||
TURNIERE_E2E_EMAIL: ${TURNIERE_E2E_EMAIL:-e2e@example.com}
|
TURNIERE_E2E_EMAIL: ${TURNIERE_E2E_EMAIL:-e2e@example.com}
|
||||||
TURNIERE_E2E_PASSWORD: ${TURNIERE_E2E_PASSWORD:-password123}
|
TURNIERE_E2E_PASSWORD: ${TURNIERE_E2E_PASSWORD:-password123}
|
||||||
TURNIERE_E2E_USERNAME: ${TURNIERE_E2E_USERNAME:-e2e-user}
|
TURNIERE_E2E_USERNAME: ${TURNIERE_E2E_USERNAME:-e2e-user}
|
||||||
|
TURNIERE_E2E_ALT_EMAIL: ${TURNIERE_E2E_ALT_EMAIL:-e2e-alt@example.com}
|
||||||
|
TURNIERE_E2E_ALT_PASSWORD: ${TURNIERE_E2E_ALT_PASSWORD:-password123}
|
||||||
|
TURNIERE_E2E_ALT_USERNAME: ${TURNIERE_E2E_ALT_USERNAME:-e2e-alt-user}
|
||||||
|
|
||||||
volumes:
|
volumes:
|
||||||
turniere-blackbox-postgres:
|
turniere-blackbox-postgres:
|
||||||
|
|
|
||||||
|
|
@ -1,8 +0,0 @@
|
||||||
# Build production container locally first with following tag
|
|
||||||
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
|
|
||||||
|
|
@ -5,7 +5,13 @@ RUN apt-get update -qq && apt-get install -y openssl git build-essential libpq-d
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
COPY Gemfile* /app/
|
COPY Gemfile* /app/
|
||||||
RUN gem install bundler:2.3.13 && bundle config set deployment 'true' && bundle install
|
RUN gem install bundler:2.3.13 && bundle config set deployment 'true' && bundle install
|
||||||
COPY . /app
|
COPY app /app/app
|
||||||
|
COPY bin /app/bin
|
||||||
|
COPY config /app/config
|
||||||
|
COPY db /app/db
|
||||||
|
COPY public /app/public
|
||||||
|
COPY script /app/script
|
||||||
|
COPY config.ru Rakefile /app/
|
||||||
# Duplicate production environment to beta environment
|
# Duplicate production environment to beta environment
|
||||||
COPY config/environments/production.rb config/environments/beta.rb
|
COPY config/environments/production.rb config/environments/beta.rb
|
||||||
ENV RAILS_ENV production
|
ENV RAILS_ENV production
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,13 @@
|
||||||
# Build development container locally first with following tag
|
# Build production container locally first with following tag
|
||||||
ARG base_commit
|
ARG base_commit
|
||||||
FROM registry.gitlab.com/turniere/turniere-backend/development/commits:$base_commit
|
FROM registry.gitlab.com/turniere/turniere-backend/production/commits:$base_commit
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
RUN bundle config set with 'test' && bundle install
|
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 test' && bundle install
|
||||||
|
COPY tasks.py /app/tasks.py
|
||||||
|
COPY e2e /app/e2e
|
||||||
|
COPY lib /app/lib
|
||||||
|
COPY spec /app/spec
|
||||||
|
COPY .rspec /app/.rspec
|
||||||
ENV RAILS_ENV test
|
ENV RAILS_ENV test
|
||||||
CMD bundle exec rails db:migrate && bundle exec rails spec
|
CMD bundle exec rails db:migrate && bundle exec rails spec
|
||||||
|
|
|
||||||
|
|
@ -3,6 +3,7 @@
|
||||||
require_relative 'api_client'
|
require_relative 'api_client'
|
||||||
require 'securerandom'
|
require 'securerandom'
|
||||||
require 'time'
|
require 'time'
|
||||||
|
require 'uri'
|
||||||
|
|
||||||
module TurniereE2E
|
module TurniereE2E
|
||||||
class ScenarioRunner
|
class ScenarioRunner
|
||||||
|
|
@ -146,6 +147,27 @@ module TurniereE2E
|
||||||
result(session: session, tournament: tournament, checkpoints: checkpoints, stop_at: stop_at)
|
result(session: session, tournament: tournament, checkpoints: checkpoints, stop_at: stop_at)
|
||||||
end
|
end
|
||||||
|
|
||||||
|
def run_group_stage_render_profile(group_count:, teams_per_group:, playoff_teams_amount:, stop_at: :profiled_show)
|
||||||
|
session = register_session!
|
||||||
|
tournament = create_tournament!(
|
||||||
|
create_group_stage_payload(
|
||||||
|
group_count: group_count,
|
||||||
|
teams_per_group: teams_per_group,
|
||||||
|
playoff_teams_amount: playoff_teams_amount
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
checkpoints = {
|
||||||
|
created: summarize_tournament(tournament)
|
||||||
|
}
|
||||||
|
return result(session: session, tournament: tournament, checkpoints: checkpoints, stop_at: stop_at) if stop_at == :created
|
||||||
|
|
||||||
|
profiled_show = fetch_tournament_profile!(tournament.fetch(:id))
|
||||||
|
checkpoints[:profiled_show] = profiled_show
|
||||||
|
|
||||||
|
result(session: session, tournament: profiled_show.fetch(:tournament), checkpoints: checkpoints, stop_at: stop_at)
|
||||||
|
end
|
||||||
|
|
||||||
def wait_for_healthcheck!(timeout_seconds: 30)
|
def wait_for_healthcheck!(timeout_seconds: 30)
|
||||||
started_at = Time.now
|
started_at = Time.now
|
||||||
loop do
|
loop do
|
||||||
|
|
@ -190,17 +212,23 @@ module TurniereE2E
|
||||||
end
|
end
|
||||||
|
|
||||||
def fetch_tournament!(tournament_id)
|
def fetch_tournament!(tournament_id)
|
||||||
response = client.get("/tournaments/#{tournament_id}")
|
response = get_response!("/tournaments/#{tournament_id}")
|
||||||
expect_status!(response, 200, "fetch tournament #{tournament_id}")
|
expect_status!(response, 200, "fetch tournament #{tournament_id}")
|
||||||
response.fetch(:json)
|
response.fetch(:json)
|
||||||
end
|
end
|
||||||
|
|
||||||
def get!(path, params: nil)
|
def get!(path, params: nil)
|
||||||
response = client.get(path, params: params)
|
response = get_response!(path, params: params)
|
||||||
expect_status!(response, 200, "get #{path}")
|
expect_status!(response, 200, "get #{path}")
|
||||||
response.fetch(:json)
|
response.fetch(:json)
|
||||||
end
|
end
|
||||||
|
|
||||||
|
def get_response!(path, params: nil)
|
||||||
|
response = client.get(path, params: params)
|
||||||
|
expect_status!(response, 200, "get #{path}")
|
||||||
|
response
|
||||||
|
end
|
||||||
|
|
||||||
def patch!(path, body:)
|
def patch!(path, body:)
|
||||||
response = client.patch(path, body: body)
|
response = client.patch(path, body: body)
|
||||||
expect_status!(response, 200, "patch #{path}")
|
expect_status!(response, 200, "patch #{path}")
|
||||||
|
|
@ -471,6 +499,31 @@ module TurniereE2E
|
||||||
}
|
}
|
||||||
end
|
end
|
||||||
|
|
||||||
|
def fetch_tournament_profile!(tournament_id)
|
||||||
|
started_at = Process.clock_gettime(Process::CLOCK_MONOTONIC)
|
||||||
|
response = get_response!("/tournaments/#{tournament_id}", params: { profile: 'true' })
|
||||||
|
duration_ms = (Process.clock_gettime(Process::CLOCK_MONOTONIC) - started_at) * 1000.0
|
||||||
|
|
||||||
|
{
|
||||||
|
tournament: response.fetch(:json),
|
||||||
|
request_duration_ms: duration_ms.round(1),
|
||||||
|
server_timing: response.fetch(:headers).fetch('server-timing', ''),
|
||||||
|
profile_summary: response.fetch(:headers).fetch('x-turniere-profile', ''),
|
||||||
|
sections: parse_server_timing(response.fetch(:headers).fetch('server-timing', ''))
|
||||||
|
}
|
||||||
|
end
|
||||||
|
|
||||||
|
def parse_server_timing(header)
|
||||||
|
return {} if header.nil? || header.empty?
|
||||||
|
|
||||||
|
header.split(',').each_with_object({}) do |entry, sections|
|
||||||
|
name, duration = entry.strip.match(/\A([^;]+);dur=([0-9.]+)\z/)&.captures
|
||||||
|
next if name.nil? || duration.nil?
|
||||||
|
|
||||||
|
sections[name] = duration.to_f
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
def result(session:, tournament:, checkpoints:, stop_at:)
|
def result(session:, tournament:, checkpoints:, stop_at:)
|
||||||
{
|
{
|
||||||
session: session,
|
session: session,
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,43 @@
|
||||||
|
# Local-Only Backend Helpers
|
||||||
|
|
||||||
|
This folder is for backend code that is useful in local development, local profiling,
|
||||||
|
test support, or E2E support, but should not be treated as normal production runtime code.
|
||||||
|
|
||||||
|
## Why this folder exists
|
||||||
|
|
||||||
|
The production Docker image currently copies these paths:
|
||||||
|
|
||||||
|
- `app`
|
||||||
|
- `bin`
|
||||||
|
- `config`
|
||||||
|
- `db`
|
||||||
|
- `public`
|
||||||
|
- `script`
|
||||||
|
- `config.ru`
|
||||||
|
- `Rakefile`
|
||||||
|
|
||||||
|
It does not copy `lib`, `spec`, `e2e`, or `tasks.py`.
|
||||||
|
|
||||||
|
That means code placed in `lib/local/` may not end up in the production image.
|
||||||
|
|
||||||
|
## What belongs here
|
||||||
|
|
||||||
|
- request profiling helpers used only in local or test flows
|
||||||
|
- support code for local verification
|
||||||
|
- helper code used by local-only workflows that should stay outside the production runtime
|
||||||
|
|
||||||
|
## What should not go here
|
||||||
|
|
||||||
|
- normal application runtime code needed in production
|
||||||
|
- code that controllers, models, jobs, or services must always have available in production
|
||||||
|
|
||||||
|
## Related special folders
|
||||||
|
|
||||||
|
Depending on purpose, local-only or non-production support code may also belong in:
|
||||||
|
|
||||||
|
- `spec/` for test-only code
|
||||||
|
- `e2e/` for reusable HTTP E2E helpers and scenarios
|
||||||
|
- task-layer code such as `tasks.py` for local/CI workflows
|
||||||
|
|
||||||
|
If code in this folder is referenced from production code paths, those call sites must guard
|
||||||
|
against production usage and fail safe when the helper is absent.
|
||||||
|
|
@ -0,0 +1,45 @@
|
||||||
|
# frozen_string_literal: true
|
||||||
|
|
||||||
|
module Local
|
||||||
|
class RequestProfiling
|
||||||
|
IGNORED_SQL_NAMES = %w[SCHEMA CACHE].freeze
|
||||||
|
IGNORED_SQL = /\A(?:BEGIN|COMMIT|ROLLBACK|SAVEPOINT|RELEASE)/i
|
||||||
|
|
||||||
|
def initialize(enabled: false)
|
||||||
|
@enabled = enabled
|
||||||
|
@measurements = []
|
||||||
|
end
|
||||||
|
|
||||||
|
def measure(name)
|
||||||
|
return yield unless @enabled
|
||||||
|
|
||||||
|
query_count = 0
|
||||||
|
subscriber = lambda do |_event_name, _start, _finish, _id, payload|
|
||||||
|
sql = payload[:sql].to_s
|
||||||
|
next if IGNORED_SQL_NAMES.include?(payload[:name])
|
||||||
|
next if sql.match?(IGNORED_SQL)
|
||||||
|
|
||||||
|
query_count += 1
|
||||||
|
end
|
||||||
|
|
||||||
|
started_at = Process.clock_gettime(Process::CLOCK_MONOTONIC)
|
||||||
|
result = nil
|
||||||
|
ActiveSupport::Notifications.subscribed(subscriber, 'sql.active_record') do
|
||||||
|
result = yield
|
||||||
|
end
|
||||||
|
duration = (Process.clock_gettime(Process::CLOCK_MONOTONIC) - started_at) * 1000.0
|
||||||
|
@measurements << { name: name, duration: duration.round(1), query_count: query_count }
|
||||||
|
result
|
||||||
|
end
|
||||||
|
|
||||||
|
def apply_to(response, label:)
|
||||||
|
return unless @enabled
|
||||||
|
|
||||||
|
response.set_header('Server-Timing', @measurements.map { |m| "#{m[:name]};dur=#{m[:duration]}" }.join(', '))
|
||||||
|
response.set_header(
|
||||||
|
'X-Turniere-Profile',
|
||||||
|
"#{label}: #{@measurements.map { |m| "#{m[:name]}=#{m[:duration]}ms/#{m[:query_count]}sql" }.join(', ')}"
|
||||||
|
)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
@ -9,6 +9,9 @@ options = {
|
||||||
base_url: ENV.fetch('TURNIERE_E2E_BASE_URL', 'http://127.0.0.1:3000'),
|
base_url: ENV.fetch('TURNIERE_E2E_BASE_URL', 'http://127.0.0.1:3000'),
|
||||||
stop_at: nil,
|
stop_at: nil,
|
||||||
team_count: 8,
|
team_count: 8,
|
||||||
|
group_count: 4,
|
||||||
|
teams_per_group: 4,
|
||||||
|
playoff_teams_amount: 8,
|
||||||
email: ENV['TURNIERE_E2E_EMAIL'],
|
email: ENV['TURNIERE_E2E_EMAIL'],
|
||||||
password: ENV['TURNIERE_E2E_PASSWORD'],
|
password: ENV['TURNIERE_E2E_PASSWORD'],
|
||||||
username: ENV['TURNIERE_E2E_USERNAME']
|
username: ENV['TURNIERE_E2E_USERNAME']
|
||||||
|
|
@ -20,6 +23,11 @@ OptionParser.new do |parser|
|
||||||
parser.on('--base-url URL', 'API base URL') { |value| options[:base_url] = value }
|
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('--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('--team-count COUNT', Integer, 'Team count for playoff_only scenario') { |value| options[:team_count] = value }
|
||||||
|
parser.on('--group-count COUNT', Integer, 'Group count for group-stage scenarios') { |value| options[:group_count] = value }
|
||||||
|
parser.on('--teams-per-group COUNT', Integer, 'Teams per group for group-stage scenarios') { |value| options[:teams_per_group] = value }
|
||||||
|
parser.on('--playoff-teams-amount COUNT', Integer, 'Playoff teams amount for group-stage scenarios') do |value|
|
||||||
|
options[:playoff_teams_amount] = value
|
||||||
|
end
|
||||||
parser.on('--email EMAIL', 'Confirmed E2E user email') { |value| options[:email] = 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('--password PASSWORD', 'Confirmed E2E user password') { |value| options[:password] = value }
|
||||||
parser.on('--username USERNAME', 'Confirmed E2E user username') { |value| options[:username] = value }
|
parser.on('--username USERNAME', 'Confirmed E2E user username') { |value| options[:username] = value }
|
||||||
|
|
@ -40,6 +48,13 @@ result = case scenario
|
||||||
runner.run_group_stage_intermediate_round(stop_at: options[:stop_at] || :final_finish_attempted)
|
runner.run_group_stage_intermediate_round(stop_at: options[:stop_at] || :final_finish_attempted)
|
||||||
when 'playoff_only'
|
when 'playoff_only'
|
||||||
runner.run_playoff_only(team_count: options[:team_count], stop_at: options[:stop_at] || :completed)
|
runner.run_playoff_only(team_count: options[:team_count], stop_at: options[:stop_at] || :completed)
|
||||||
|
when 'group_stage_render_profile'
|
||||||
|
runner.run_group_stage_render_profile(
|
||||||
|
group_count: options[:group_count],
|
||||||
|
teams_per_group: options[:teams_per_group],
|
||||||
|
playoff_teams_amount: options[:playoff_teams_amount],
|
||||||
|
stop_at: options[:stop_at] || :profiled_show
|
||||||
|
)
|
||||||
else
|
else
|
||||||
abort("Unknown scenario #{scenario.inspect}")
|
abort("Unknown scenario #{scenario.inspect}")
|
||||||
end
|
end
|
||||||
|
|
|
||||||
|
|
@ -3,6 +3,35 @@
|
||||||
require 'rails_helper'
|
require 'rails_helper'
|
||||||
|
|
||||||
RSpec.describe StagesController, type: :controller do
|
RSpec.describe StagesController, type: :controller do
|
||||||
|
def create_finished_resolved_group_stage(group_count: 4, teams_per_group: 2)
|
||||||
|
tournament = create(
|
||||||
|
:tournament,
|
||||||
|
teams_count: group_count * teams_per_group,
|
||||||
|
playoff_teams_amount: group_count,
|
||||||
|
instant_finalists_amount: group_count,
|
||||||
|
intermediate_round_participants_amount: 0
|
||||||
|
)
|
||||||
|
groups = tournament.teams.each_slice(teams_per_group).to_a
|
||||||
|
stage = GroupStageService.generate_group_stage(groups)
|
||||||
|
tournament.stages << stage
|
||||||
|
tournament.save!
|
||||||
|
|
||||||
|
stage.groups.each do |group|
|
||||||
|
group.matches.each do |match|
|
||||||
|
match.state = :finished
|
||||||
|
match.match_scores.each_with_index do |match_score, index|
|
||||||
|
match_score.points = index.zero? ? 1 : 0
|
||||||
|
match_score.save!
|
||||||
|
end
|
||||||
|
match.save!
|
||||||
|
end
|
||||||
|
|
||||||
|
UpdateGroupsGroupScoresAndSave.call(group: group)
|
||||||
|
end
|
||||||
|
|
||||||
|
stage.reload
|
||||||
|
end
|
||||||
|
|
||||||
let(:stage) do
|
let(:stage) do
|
||||||
create(:playoff_stage)
|
create(:playoff_stage)
|
||||||
end
|
end
|
||||||
|
|
@ -29,7 +58,7 @@ RSpec.describe StagesController, type: :controller do
|
||||||
|
|
||||||
context 'group_stage with matches that are done' do
|
context 'group_stage with matches that are done' do
|
||||||
let(:running_group_stage) do
|
let(:running_group_stage) do
|
||||||
create(:group_stage, match_factory: :finished_group_match)
|
create_finished_resolved_group_stage
|
||||||
end
|
end
|
||||||
|
|
||||||
it 'doesn\'t have any other stages besides it before update' do
|
it 'doesn\'t have any other stages besides it before update' do
|
||||||
|
|
@ -98,7 +127,7 @@ RSpec.describe StagesController, type: :controller do
|
||||||
|
|
||||||
context 'already finished group stage' do
|
context 'already finished group stage' do
|
||||||
let(:finished_group_stage) do
|
let(:finished_group_stage) do
|
||||||
group_stage = create(:group_stage, match_factory: :finished_group_match)
|
group_stage = create_finished_resolved_group_stage
|
||||||
group_stage.finished!
|
group_stage.finished!
|
||||||
group_stage.save!
|
group_stage.save!
|
||||||
group_stage
|
group_stage
|
||||||
|
|
|
||||||
|
|
@ -35,7 +35,8 @@ RSpec.describe StatisticsController, type: :controller do
|
||||||
|
|
||||||
it 'returns a list containing all group scores' do
|
it 'returns a list containing all group scores' do
|
||||||
get :index, params: { tournament_id: @tournament.to_param }
|
get :index, params: { tournament_id: @tournament.to_param }
|
||||||
expect(deserialize_response(response)[:group_scores].length).to eq(GroupScore.count)
|
expected_count = @group_stage.groups.sum { |group| group.group_scores.size }
|
||||||
|
expect(deserialize_response(response)[:group_scores].length).to eq(expected_count)
|
||||||
end
|
end
|
||||||
|
|
||||||
it 'returns a most dominant group score' do
|
it 'returns a most dominant group score' do
|
||||||
|
|
|
||||||
|
|
@ -118,6 +118,26 @@ RSpec.describe TournamentsController, type: :controller do
|
||||||
expect(body[:teams]).to be_nil
|
expect(body[:teams]).to be_nil
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
|
context 'with profile=true parameter' do
|
||||||
|
it 'adds profiling headers outside production' do
|
||||||
|
get :show, params: { id: @tournament.to_param, profile: 'true' }
|
||||||
|
|
||||||
|
expect(response.headers['Server-Timing']).to include('load_tournament')
|
||||||
|
expect(response.headers['Server-Timing']).to include('serialize_tournament')
|
||||||
|
expect(response.headers['X-Turniere-Profile']).to include('tournament.show')
|
||||||
|
end
|
||||||
|
|
||||||
|
it 'ignores profiling in production' do
|
||||||
|
allow(Rails).to receive(:env).and_return(ActiveSupport::StringInquirer.new('production'))
|
||||||
|
|
||||||
|
get :show, params: { id: @tournament.to_param, profile: 'true' }
|
||||||
|
|
||||||
|
expect(response.headers['Server-Timing']).to be_nil
|
||||||
|
expect(response.headers['X-Turniere-Profile']).to be_nil
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
context 'on a group stage tournament' do
|
context 'on a group stage tournament' do
|
||||||
before do
|
before do
|
||||||
@group_stage_tournament = create(:group_stage_tournament)
|
@group_stage_tournament = create(:group_stage_tournament)
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,60 @@
|
||||||
|
# frozen_string_literal: true
|
||||||
|
|
||||||
|
require 'spec_helper'
|
||||||
|
require_relative '../../../e2e/lib/scenario_runner'
|
||||||
|
|
||||||
|
RSpec.describe 'Tournament rendering 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(:expect_profiling) { ENV.fetch('TURNIERE_E2E_EXPECT_PROFILING', 'true') == 'true' }
|
||||||
|
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
|
||||||
|
|
||||||
|
[
|
||||||
|
{ group_count: 8, teams_per_group: 4, playoff_teams_amount: 8, max_request_duration_ms: 1000, max_server_duration_ms: 1000 },
|
||||||
|
{ group_count: 64, teams_per_group: 6, playoff_teams_amount: 64, max_request_duration_ms: 5000, max_server_duration_ms: 5000 },
|
||||||
|
{ group_count: 4, teams_per_group: 32, playoff_teams_amount: 4, max_request_duration_ms: 5000, max_server_duration_ms: 5000 }
|
||||||
|
].each do |scenario|
|
||||||
|
it "creates #{scenario[:group_count]}x#{scenario[:teams_per_group]} via API and renders within budget" do
|
||||||
|
result = runner.run_group_stage_render_profile(
|
||||||
|
group_count: scenario[:group_count],
|
||||||
|
teams_per_group: scenario[:teams_per_group],
|
||||||
|
playoff_teams_amount: scenario[:playoff_teams_amount]
|
||||||
|
)
|
||||||
|
|
||||||
|
profiled_show = result.dig(:checkpoints, :profiled_show)
|
||||||
|
sections = profiled_show.fetch(:sections)
|
||||||
|
total_server_duration_ms = sections.values.sum
|
||||||
|
tournament = profiled_show.fetch(:tournament)
|
||||||
|
group_stage = tournament.fetch(:stages).find { |stage| stage.fetch(:level) == -1 }
|
||||||
|
|
||||||
|
expect(group_stage.fetch(:groups).size).to eq(scenario[:group_count])
|
||||||
|
expect(tournament.fetch(:teams).size).to eq(scenario[:group_count] * scenario[:teams_per_group])
|
||||||
|
if expect_profiling
|
||||||
|
expect(profiled_show.fetch(:server_timing)).to include('load_tournament')
|
||||||
|
expect(profiled_show.fetch(:server_timing)).to include('serialize_tournament')
|
||||||
|
expect(profiled_show.fetch(:profile_summary)).to include('sql')
|
||||||
|
expect(sections.fetch('load_tournament')).to be <= scenario[:max_server_duration_ms]
|
||||||
|
expect(sections.fetch('serialize_tournament')).to be <= scenario[:max_server_duration_ms]
|
||||||
|
expect(total_server_duration_ms).to be <= scenario[:max_server_duration_ms]
|
||||||
|
expect(profiled_show.fetch(:request_duration_ms)).to be <= scenario[:max_request_duration_ms]
|
||||||
|
else
|
||||||
|
expect(profiled_show.fetch(:server_timing)).to eq('')
|
||||||
|
expect(profiled_show.fetch(:profile_summary)).to eq('')
|
||||||
|
expect(sections).to eq({})
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
@ -32,3 +32,13 @@ Observed and covered by `spec/e2e/http/tournament_lifecycle_spec.rb` and `script
|
||||||
- `1` regular match
|
- `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.
|
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.
|
||||||
|
|
||||||
|
## Large tournament render profiling
|
||||||
|
|
||||||
|
Observed and covered by `spec/e2e/http/tournament_rendering_spec.rb` and `script/e2e_scenarios.rb`:
|
||||||
|
|
||||||
|
- large group-stage tournaments are created through the same authenticated HTTP `POST /tournaments` flow as normal clients
|
||||||
|
- tournament show profiling is requested via `GET /tournaments/:id?profile=true`
|
||||||
|
- profiling is intentionally non-production-only; local/test flows may use it, production should ignore it
|
||||||
|
- local/test runs return `Server-Timing` and `X-Turniere-Profile` headers with `load_tournament` and `serialize_tournament`
|
||||||
|
- current locked shapes are `8x4`, `64x6`, and `4x32`
|
||||||
|
|
|
||||||
|
|
@ -63,6 +63,11 @@ FactoryBot.define do
|
||||||
|
|
||||||
factory :finished_group_match do
|
factory :finished_group_match do
|
||||||
state { :finished }
|
state { :finished }
|
||||||
|
after(:create) do |match|
|
||||||
|
match.match_scores.order(:id).each_with_index do |match_score, index|
|
||||||
|
match_score.update!(points: index.zero? ? 1 : 0)
|
||||||
|
end
|
||||||
|
end
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -2,8 +2,8 @@
|
||||||
|
|
||||||
FactoryBot.define do
|
FactoryBot.define do
|
||||||
factory :user do
|
factory :user do
|
||||||
username { Faker::Internet.unique.username }
|
sequence(:username) { |n| "user#{n}" }
|
||||||
email { Faker::Internet.unique.email }
|
sequence(:email) { |n| "user#{n}@example.com" }
|
||||||
password { Faker::Internet.password }
|
password { 'password123' }
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
|
||||||
|
|
@ -78,9 +78,9 @@ RSpec.describe GroupScore, type: :model do
|
||||||
|
|
||||||
it 'reports the direct comparison result when head-to-head resolves the tie' do
|
it 'reports the direct comparison result when head-to-head resolves the tie' do
|
||||||
group_score_b.update!(group_points: 6)
|
group_score_b.update!(group_points: 6)
|
||||||
create(:group_match, group: group, state: :finished).tap do |match|
|
match = create(:group_match, group: group, state: :finished).tap do |created_match|
|
||||||
create(:match_score, match: match, team: team_a, points: 3)
|
create(:match_score, match: created_match, team: team_a, points: 3)
|
||||||
create(:match_score, match: match, team: team_b, points: 1)
|
create(:match_score, match: created_match, team: team_b, points: 1)
|
||||||
end
|
end
|
||||||
|
|
||||||
expect(group_score_a.comparison_reason_against(group_score_b)).to eq(
|
expect(group_score_a.comparison_reason_against(group_score_b)).to eq(
|
||||||
|
|
@ -88,16 +88,16 @@ RSpec.describe GroupScore, type: :model do
|
||||||
tied: false,
|
tied: false,
|
||||||
direct_comparison_result: :won,
|
direct_comparison_result: :won,
|
||||||
hidden_points_result: :not_played,
|
hidden_points_result: :not_played,
|
||||||
direct_comparison_match_id: group.matches.last.id,
|
direct_comparison_match_id: match.id,
|
||||||
decider_match_id: nil
|
decider_match_id: nil
|
||||||
)
|
)
|
||||||
end
|
end
|
||||||
|
|
||||||
it 'reports an unresolved tie when head-to-head was a draw' do
|
it 'reports an unresolved tie when head-to-head was a draw' do
|
||||||
group_score_b.update!(group_points: 6)
|
group_score_b.update!(group_points: 6)
|
||||||
create(:group_match, group: group, state: :finished).tap do |match|
|
match = create(:group_match, group: group, state: :finished).tap do |created_match|
|
||||||
create(:match_score, match: match, team: team_a, points: 2)
|
create(:match_score, match: created_match, team: team_a, points: 2)
|
||||||
create(:match_score, match: match, team: team_b, points: 2)
|
create(:match_score, match: created_match, team: team_b, points: 2)
|
||||||
end
|
end
|
||||||
|
|
||||||
expect(group_score_a.comparison_reason_against(group_score_b)).to eq(
|
expect(group_score_a.comparison_reason_against(group_score_b)).to eq(
|
||||||
|
|
@ -105,20 +105,20 @@ RSpec.describe GroupScore, type: :model do
|
||||||
tied: true,
|
tied: true,
|
||||||
direct_comparison_result: :draw,
|
direct_comparison_result: :draw,
|
||||||
hidden_points_result: :not_played,
|
hidden_points_result: :not_played,
|
||||||
direct_comparison_match_id: group.matches.last.id,
|
direct_comparison_match_id: match.id,
|
||||||
decider_match_id: nil
|
decider_match_id: nil
|
||||||
)
|
)
|
||||||
end
|
end
|
||||||
|
|
||||||
it 'falls back to hidden points when direct comparison is still tied' do
|
it 'falls back to hidden points when direct comparison is still tied' do
|
||||||
group_score_b.update!(group_points: 6)
|
group_score_b.update!(group_points: 6)
|
||||||
create(:group_match, group: group, state: :finished).tap do |match|
|
direct_match = create(:group_match, group: group, state: :finished).tap do |created_match|
|
||||||
create(:match_score, match: match, team: team_a, points: 2)
|
create(:match_score, match: created_match, team: team_a, points: 2)
|
||||||
create(:match_score, match: match, team: team_b, points: 2)
|
create(:match_score, match: created_match, team: team_b, points: 2)
|
||||||
end
|
end
|
||||||
create(:group_match, group: group, state: :finished, decider: true).tap do |match|
|
decider_match = create(:group_match, group: group, state: :finished, decider: true).tap do |created_match|
|
||||||
create(:match_score, match: match, team: team_a, hidden_points: 3)
|
create(:match_score, match: created_match, team: team_a, hidden_points: 3)
|
||||||
create(:match_score, match: match, team: team_b, hidden_points: 1)
|
create(:match_score, match: created_match, team: team_b, hidden_points: 1)
|
||||||
end
|
end
|
||||||
|
|
||||||
expect(group_score_a.comparison_reason_against(group_score_b)).to eq(
|
expect(group_score_a.comparison_reason_against(group_score_b)).to eq(
|
||||||
|
|
@ -126,8 +126,8 @@ RSpec.describe GroupScore, type: :model do
|
||||||
tied: false,
|
tied: false,
|
||||||
direct_comparison_result: :draw,
|
direct_comparison_result: :draw,
|
||||||
hidden_points_result: :won,
|
hidden_points_result: :won,
|
||||||
direct_comparison_match_id: group.matches.reject(&:decider?).last.id,
|
direct_comparison_match_id: direct_match.id,
|
||||||
decider_match_id: group.matches.select(&:decider?).last.id
|
decider_match_id: decider_match.id
|
||||||
)
|
)
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
|
||||||
|
|
@ -109,21 +109,21 @@ RSpec.describe Match, type: :model do
|
||||||
end
|
end
|
||||||
|
|
||||||
it 'returns correct group_points' do
|
it 'returns correct group_points' do
|
||||||
expect(@match.group_points_of(@team1)).to be(1)
|
expect(@match.group_points_of(@team1)).to eq(1)
|
||||||
expect(@match.group_points_of(@team2)).to be(1)
|
expect(@match.group_points_of(@team2)).to eq(1)
|
||||||
expect(@match.group_points_of(@uninvolved_team)).to be(0)
|
expect(@match.group_points_of(@uninvolved_team)).to eq(0)
|
||||||
end
|
end
|
||||||
|
|
||||||
it 'returns correct scored_points' do
|
it 'returns correct scored_points' do
|
||||||
expect(@match.scored_points_of(@team1)).to be(34)
|
expect(@match.scored_points_of(@team1)).to eq(34)
|
||||||
expect(@match.scored_points_of(@team2)).to be(34)
|
expect(@match.scored_points_of(@team2)).to eq(34)
|
||||||
expect(@match.scored_points_of(@uninvolved_team)).to be(0)
|
expect(@match.scored_points_of(@uninvolved_team)).to eq(0)
|
||||||
end
|
end
|
||||||
|
|
||||||
it 'returns correct received_points' do
|
it 'returns correct received_points' do
|
||||||
expect(@match.received_points_of(@team1)).to be(34)
|
expect(@match.received_points_of(@team1)).to eq(34)
|
||||||
expect(@match.received_points_of(@team2)).to be(34)
|
expect(@match.received_points_of(@team2)).to eq(34)
|
||||||
expect(@match.received_points_of(@uninvolved_team)).to be(0)
|
expect(@match.received_points_of(@uninvolved_team)).to eq(0)
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
|
|
@ -134,15 +134,15 @@ RSpec.describe Match, type: :model do
|
||||||
end
|
end
|
||||||
|
|
||||||
it 'returns correct group_points' do
|
it 'returns correct group_points' do
|
||||||
expect(@not_started_match.group_points_of(@team1)).to be(0)
|
expect(@not_started_match.group_points_of(@team1)).to eq(0)
|
||||||
end
|
end
|
||||||
|
|
||||||
it 'returns correct scored_points' do
|
it 'returns correct scored_points' do
|
||||||
expect(@match.scored_points_of(@team1)).to be(0)
|
expect(@not_started_match.scored_points_of(@team1)).to eq(0)
|
||||||
end
|
end
|
||||||
|
|
||||||
it 'returns correct received_points' do
|
it 'returns correct received_points' do
|
||||||
expect(@match.received_points_of(@team1)).to be(0)
|
expect(@not_started_match.received_points_of(@team1)).to eq(0)
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
|
|
@ -155,21 +155,21 @@ RSpec.describe Match, type: :model do
|
||||||
end
|
end
|
||||||
|
|
||||||
it 'returns correct group_points' do
|
it 'returns correct group_points' do
|
||||||
expect(@match.group_points_of(@team1)).to be(3)
|
expect(@match.group_points_of(@team1)).to eq(3)
|
||||||
expect(@match.group_points_of(@team2)).to be(0)
|
expect(@match.group_points_of(@team2)).to eq(0)
|
||||||
expect(@match.group_points_of(@uninvolved_team)).to be(0)
|
expect(@match.group_points_of(@uninvolved_team)).to eq(0)
|
||||||
end
|
end
|
||||||
|
|
||||||
it 'returns correct scored_points' do
|
it 'returns correct scored_points' do
|
||||||
expect(@match.scored_points_of(@team1)).to be(42)
|
expect(@match.scored_points_of(@team1)).to eq(42)
|
||||||
expect(@match.scored_points_of(@team2)).to be(17)
|
expect(@match.scored_points_of(@team2)).to eq(17)
|
||||||
expect(@match.scored_points_of(@uninvolved_team)).to be(0)
|
expect(@match.scored_points_of(@uninvolved_team)).to eq(0)
|
||||||
end
|
end
|
||||||
|
|
||||||
it 'returns correct received_points' do
|
it 'returns correct received_points' do
|
||||||
expect(@match.received_points_of(@team1)).to be(17)
|
expect(@match.received_points_of(@team1)).to eq(17)
|
||||||
expect(@match.received_points_of(@team2)).to be(42)
|
expect(@match.received_points_of(@team2)).to eq(42)
|
||||||
expect(@match.received_points_of(@uninvolved_team)).to be(0)
|
expect(@match.received_points_of(@uninvolved_team)).to eq(0)
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,37 @@
|
||||||
|
# frozen_string_literal: true
|
||||||
|
|
||||||
|
require 'rails_helper'
|
||||||
|
require_relative '../support/performance_helpers'
|
||||||
|
|
||||||
|
RSpec.describe 'Tournament rendering performance', type: :request do
|
||||||
|
[
|
||||||
|
{ groups_count: 8, teams_per_group: 4, max_duration_ms: 1000 },
|
||||||
|
{ groups_count: 64, teams_per_group: 6, max_duration_ms: 5000 },
|
||||||
|
{ groups_count: 4, teams_per_group: 32, max_duration_ms: 5000 }
|
||||||
|
].each do |scenario|
|
||||||
|
it "renders #{scenario[:groups_count]} groups with #{scenario[:teams_per_group]} teams within budget" do
|
||||||
|
tournament = create_group_stage_tournament(
|
||||||
|
groups_count: scenario[:groups_count],
|
||||||
|
teams_per_group: scenario[:teams_per_group]
|
||||||
|
)
|
||||||
|
|
||||||
|
get "/tournaments/#{tournament.id}"
|
||||||
|
expect(response).to have_http_status(:ok)
|
||||||
|
|
||||||
|
performance = capture_runtime_and_queries do
|
||||||
|
get "/tournaments/#{tournament.id}", params: { profile: 'true' }
|
||||||
|
end
|
||||||
|
|
||||||
|
expect(response).to have_http_status(:ok)
|
||||||
|
expect(response.headers['Server-Timing']).to include('load_tournament')
|
||||||
|
expect(response.headers['Server-Timing']).to include('serialize_tournament')
|
||||||
|
expect(response.headers['X-Turniere-Profile']).to include('tournament.show')
|
||||||
|
expect(response.headers['X-Turniere-Profile']).to include('sql')
|
||||||
|
|
||||||
|
body = response.parsed_body
|
||||||
|
expect(body.fetch('stages').first.fetch('groups').size).to eq(scenario[:groups_count])
|
||||||
|
expect(body.fetch('teams').size).to eq(scenario[:groups_count] * scenario[:teams_per_group])
|
||||||
|
expect(performance[:duration_ms]).to be <= scenario[:max_duration_ms]
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
@ -89,6 +89,14 @@ RSpec.describe GroupStageService do
|
||||||
expect(match.teams.first).to_not eq(match.teams.second)
|
expect(match.teams.first).to_not eq(match.teams.second)
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
|
it 'also generates matches for group sizes other than four' do
|
||||||
|
teams = create_list(:team, 6)
|
||||||
|
|
||||||
|
matches = GroupStageService.generate_all_matches_between(teams)
|
||||||
|
|
||||||
|
expect(matches.size).to eq(15)
|
||||||
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
describe '#update_group_scores' do
|
describe '#update_group_scores' do
|
||||||
|
|
@ -102,7 +110,7 @@ RSpec.describe GroupStageService do
|
||||||
|
|
||||||
context 'with only undecided matches' do
|
context 'with only undecided matches' do
|
||||||
before do
|
before do
|
||||||
@group = create(:group, match_factory: :running_group_match)
|
@group = create(:group, match_count: 1, match_factory: :running_group_match)
|
||||||
@group.matches.each do |match|
|
@group.matches.each do |match|
|
||||||
match.match_scores.each do |ms|
|
match.match_scores.each do |ms|
|
||||||
ms.points = 42
|
ms.points = 42
|
||||||
|
|
@ -135,7 +143,7 @@ RSpec.describe GroupStageService do
|
||||||
|
|
||||||
context 'with only decided matches' do
|
context 'with only decided matches' do
|
||||||
before do
|
before do
|
||||||
@group = create(:group, match_factory: :running_group_match)
|
@group = create(:group, match_count: 1, match_factory: :running_group_match)
|
||||||
@group.matches.each_with_index do |match, i|
|
@group.matches.each_with_index do |match, i|
|
||||||
match.match_scores.each_with_index do |ms, j|
|
match.match_scores.each_with_index do |ms, j|
|
||||||
match_score_number = i + j
|
match_score_number = i + j
|
||||||
|
|
@ -292,7 +300,26 @@ RSpec.describe GroupStageService do
|
||||||
)
|
)
|
||||||
end
|
end
|
||||||
|
|
||||||
it 'marks unresolved ties outside the advancing cutoff without requesting a decider' do
|
it 'does not expose unresolved tie metadata while regular matches in the group are still unfinished' do
|
||||||
|
tournament.update!(playoff_teams_amount: 1, instant_finalists_amount: 1, intermediate_round_participants_amount: 0)
|
||||||
|
|
||||||
|
create(:group_match, group: group, state: :in_progress).tap do |match|
|
||||||
|
create(:match_score, match: match, team: team_a, points: 2)
|
||||||
|
create(:match_score, match: match, team: team_b, points: 2)
|
||||||
|
end
|
||||||
|
|
||||||
|
score_a = create(:group_score, group: group, team: team_a, group_points: 1, scored_points: 2, received_points: 2)
|
||||||
|
score_b = create(:group_score, group: group, team: team_b, group_points: 1, scored_points: 2, received_points: 2)
|
||||||
|
score_c = create(:group_score, group: group, team: team_c, group_points: 0, scored_points: 0, received_points: 0)
|
||||||
|
score_d = create(:group_score, group: group, team: team_d, group_points: 0, scored_points: 0, received_points: 0)
|
||||||
|
|
||||||
|
GroupStageService.send(:recalculate_position_of_group_scores!, [score_a, score_b, score_c, score_d]).each(&:save!)
|
||||||
|
|
||||||
|
expect(GroupStageService.ranking_decision_for(score_a)).to be_nil
|
||||||
|
expect(GroupStageService.ranking_decision_for(score_b)).to be_nil
|
||||||
|
end
|
||||||
|
|
||||||
|
it 'does not expose unresolved tie metadata outside the advancing cutoff before the group is finished' do
|
||||||
tournament.update!(playoff_teams_amount: 2, instant_finalists_amount: 2, intermediate_round_participants_amount: 0)
|
tournament.update!(playoff_teams_amount: 2, instant_finalists_amount: 2, intermediate_round_participants_amount: 0)
|
||||||
|
|
||||||
score_a = create(:group_score, group: group, team: team_a, group_points: 9, scored_points: 5, received_points: 1, position: 1)
|
score_a = create(:group_score, group: group, team: team_a, group_points: 9, scored_points: 5, received_points: 1, position: 1)
|
||||||
|
|
@ -303,18 +330,8 @@ RSpec.describe GroupStageService do
|
||||||
GroupStageService.send(:recalculate_position_of_group_scores!, [score_a, score_b, score_c, score_d]).each(&:save!)
|
GroupStageService.send(:recalculate_position_of_group_scores!, [score_a, score_b, score_c, score_d]).each(&:save!)
|
||||||
|
|
||||||
expect([score_a.position, score_b.position, score_c.position, score_d.position]).to eq([1, 2, 3, 3])
|
expect([score_a.position, score_b.position, score_c.position, score_d.position]).to eq([1, 2, 3, 3])
|
||||||
expect(GroupStageService.ranking_decision_for(score_c)).to include(
|
expect(GroupStageService.ranking_decision_for(score_c)).to be_nil
|
||||||
resolved_by: 'tie',
|
expect(GroupStageService.ranking_decision_for(score_d)).to be_nil
|
||||||
hidden_points_result: 'not_played',
|
|
||||||
tied: true,
|
|
||||||
needs_decider_match: false
|
|
||||||
)
|
|
||||||
expect(GroupStageService.ranking_decision_for(score_d)).to include(
|
|
||||||
resolved_by: 'tie',
|
|
||||||
hidden_points_result: 'not_played',
|
|
||||||
tied: true,
|
|
||||||
needs_decider_match: false
|
|
||||||
)
|
|
||||||
end
|
end
|
||||||
|
|
||||||
it 'uses the decider hidden points to resolve a cutoff tie' do
|
it 'uses the decider hidden points to resolve a cutoff tie' do
|
||||||
|
|
|
||||||
|
|
@ -5,6 +5,7 @@ RSpec.describe StatisticsService do
|
||||||
# build tournament with predictable test data
|
# build tournament with predictable test data
|
||||||
tournament = create(:tournament)
|
tournament = create(:tournament)
|
||||||
group_stage = create(:group_stage)
|
group_stage = create(:group_stage)
|
||||||
|
@group_stage = group_stage
|
||||||
group = group_stage.groups.first
|
group = group_stage.groups.first
|
||||||
@most_dominant_score = create(:group_score,
|
@most_dominant_score = create(:group_score,
|
||||||
group_points: 100,
|
group_points: 100,
|
||||||
|
|
@ -48,7 +49,7 @@ RSpec.describe StatisticsService do
|
||||||
|
|
||||||
describe '#group_scores' do
|
describe '#group_scores' do
|
||||||
it 'returns an array containing all group scores' do
|
it 'returns an array containing all group scores' do
|
||||||
expect(@service.group_scores.length).to eq(GroupScore.count)
|
expect(@service.group_scores.length).to eq(@group_stage.groups.sum { |group| group.group_scores.size })
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,55 @@
|
||||||
|
# frozen_string_literal: true
|
||||||
|
|
||||||
|
module PerformanceHelpers
|
||||||
|
IGNORED_SQL_NAMES = %w[SCHEMA CACHE].freeze
|
||||||
|
IGNORED_SQL = /\A(?:BEGIN|COMMIT|ROLLBACK|SAVEPOINT|RELEASE)/i
|
||||||
|
|
||||||
|
def capture_runtime_and_queries
|
||||||
|
query_count = 0
|
||||||
|
subscriber = lambda do |_name, _start, _finish, _id, payload|
|
||||||
|
sql = payload[:sql].to_s
|
||||||
|
next if IGNORED_SQL_NAMES.include?(payload[:name])
|
||||||
|
next if sql.match?(IGNORED_SQL)
|
||||||
|
|
||||||
|
query_count += 1
|
||||||
|
end
|
||||||
|
|
||||||
|
started_at = Process.clock_gettime(Process::CLOCK_MONOTONIC)
|
||||||
|
result = nil
|
||||||
|
|
||||||
|
ActiveSupport::Notifications.subscribed(subscriber, 'sql.active_record') do
|
||||||
|
result = yield
|
||||||
|
end
|
||||||
|
|
||||||
|
duration_ms = (Process.clock_gettime(Process::CLOCK_MONOTONIC) - started_at) * 1000.0
|
||||||
|
{ result: result, query_count: query_count, duration_ms: duration_ms }
|
||||||
|
end
|
||||||
|
|
||||||
|
def create_group_stage_tournament(groups_count:, teams_per_group:)
|
||||||
|
owner = create(:user)
|
||||||
|
tournament = owner.tournaments.create!(
|
||||||
|
name: "Perf #{groups_count}x#{teams_per_group}",
|
||||||
|
description: 'Tournament render performance fixture',
|
||||||
|
public: true,
|
||||||
|
playoff_teams_amount: groups_count
|
||||||
|
)
|
||||||
|
|
||||||
|
groups = Array.new(groups_count) do |group_index|
|
||||||
|
Array.new(teams_per_group) do |team_index|
|
||||||
|
tournament.teams.create!(name: "G#{group_index + 1}-T#{team_index + 1}")
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
group_stage = GroupStageService.generate_group_stage(groups)
|
||||||
|
tournament.stages = [group_stage]
|
||||||
|
tournament.instant_finalists_amount, tournament.intermediate_round_participants_amount =
|
||||||
|
TournamentService.calculate_default_amount_of_teams_advancing(tournament.playoff_teams_amount, groups_count)
|
||||||
|
tournament.save!
|
||||||
|
|
||||||
|
tournament.reload
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
RSpec.configure do |config|
|
||||||
|
config.include PerformanceHelpers
|
||||||
|
end
|
||||||
167
tasks.py
167
tasks.py
|
|
@ -26,7 +26,6 @@ E2E_ALT_PASSWORD = "password123"
|
||||||
E2E_ALT_USERNAME = "e2e-alt-user"
|
E2E_ALT_USERNAME = "e2e-alt-user"
|
||||||
|
|
||||||
PRODUCTION_TAG = "registry.gitlab.com/turniere/turniere-backend/production/commits:local"
|
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"
|
TEST_TAG = "registry.gitlab.com/turniere/turniere-backend/test/commits:local"
|
||||||
BLACKBOX_COMPOSE_FILE = "docker-compose.blackbox.yml"
|
BLACKBOX_COMPOSE_FILE = "docker-compose.blackbox.yml"
|
||||||
BLACKBOX_PROJECT = "turniere-blackbox"
|
BLACKBOX_PROJECT = "turniere-blackbox"
|
||||||
|
|
@ -40,6 +39,8 @@ BLACKBOX_MAILGUN_DOMAIN = "blackbox.example.com"
|
||||||
SPEC_ROOT = Path("spec")
|
SPEC_ROOT = Path("spec")
|
||||||
E2E_SPEC_ROOT = SPEC_ROOT / "e2e" / "http"
|
E2E_SPEC_ROOT = SPEC_ROOT / "e2e" / "http"
|
||||||
BUNDLER_VERSION = Path("Gemfile.lock").read_text(encoding="utf-8").split("BUNDLED WITH", 1)[1].strip().splitlines()[-1].strip()
|
BUNDLER_VERSION = Path("Gemfile.lock").read_text(encoding="utf-8").split("BUNDLED WITH", 1)[1].strip().splitlines()[-1].strip()
|
||||||
|
ROOT = Path(__file__).resolve().parent
|
||||||
|
DB_DIR = ROOT / "db"
|
||||||
|
|
||||||
|
|
||||||
def _env(**overrides):
|
def _env(**overrides):
|
||||||
|
|
@ -52,6 +53,30 @@ def _rails_env(env=None):
|
||||||
return str(env or os.environ.get("RAILS_ENV", "development"))
|
return str(env or os.environ.get("RAILS_ENV", "development"))
|
||||||
|
|
||||||
|
|
||||||
|
def _sqlite_db_path(env=None):
|
||||||
|
rails_env = _rails_env(env)
|
||||||
|
|
||||||
|
if rails_env == "development":
|
||||||
|
return DB_DIR / "development.sqlite3"
|
||||||
|
if rails_env == "test":
|
||||||
|
return DB_DIR / "test.sqlite3"
|
||||||
|
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def _delete_sqlite_db(env=None):
|
||||||
|
db_path = _sqlite_db_path(env)
|
||||||
|
if db_path is None:
|
||||||
|
return None
|
||||||
|
|
||||||
|
for suffix in ("", "-shm", "-wal", ".journal"):
|
||||||
|
candidate = Path(f"{db_path}{suffix}")
|
||||||
|
if candidate.exists():
|
||||||
|
candidate.unlink()
|
||||||
|
|
||||||
|
return db_path
|
||||||
|
|
||||||
|
|
||||||
def _bundle_command(*parts):
|
def _bundle_command(*parts):
|
||||||
return " ".join(["bundle", f"_{BUNDLER_VERSION}_", *parts])
|
return " ".join(["bundle", f"_{BUNDLER_VERSION}_", *parts])
|
||||||
|
|
||||||
|
|
@ -106,11 +131,37 @@ def _run_subprocess(command, env=None, capture_output=False, check=True):
|
||||||
return completed
|
return completed
|
||||||
|
|
||||||
|
|
||||||
|
def _stream_subprocess(command, env=None, check=True):
|
||||||
|
process = subprocess.Popen(
|
||||||
|
command,
|
||||||
|
env=env,
|
||||||
|
text=True,
|
||||||
|
stdout=subprocess.PIPE,
|
||||||
|
stderr=subprocess.STDOUT,
|
||||||
|
bufsize=1,
|
||||||
|
)
|
||||||
|
|
||||||
|
output_chunks = []
|
||||||
|
assert process.stdout is not None
|
||||||
|
for line in process.stdout:
|
||||||
|
print(line, end="")
|
||||||
|
output_chunks.append(line)
|
||||||
|
|
||||||
|
process.wait()
|
||||||
|
output = "".join(output_chunks)
|
||||||
|
|
||||||
|
if check and process.returncode != 0:
|
||||||
|
raise subprocess.CalledProcessError(process.returncode, command, output=output, stderr=None)
|
||||||
|
|
||||||
|
return subprocess.CompletedProcess(command, process.returncode, stdout=output, stderr="")
|
||||||
|
|
||||||
|
|
||||||
def _compose_env(
|
def _compose_env(
|
||||||
host_port=SERVER_PORT,
|
host_port=SERVER_PORT,
|
||||||
app_image=PRODUCTION_TAG,
|
app_image=PRODUCTION_TAG,
|
||||||
runner_image=TEST_TAG,
|
runner_image=TEST_TAG,
|
||||||
postgres_image=BLACKBOX_POSTGRES_IMAGE,
|
postgres_image=BLACKBOX_POSTGRES_IMAGE,
|
||||||
|
expect_profiling="true",
|
||||||
):
|
):
|
||||||
return _env(
|
return _env(
|
||||||
TURNIERE_BLACKBOX_HOST_PORT=host_port,
|
TURNIERE_BLACKBOX_HOST_PORT=host_port,
|
||||||
|
|
@ -124,6 +175,7 @@ def _compose_env(
|
||||||
TURNIERE_BLACKBOX_MAILGUN_API_KEY=BLACKBOX_MAILGUN_API_KEY,
|
TURNIERE_BLACKBOX_MAILGUN_API_KEY=BLACKBOX_MAILGUN_API_KEY,
|
||||||
TURNIERE_BLACKBOX_MAILGUN_DOMAIN=BLACKBOX_MAILGUN_DOMAIN,
|
TURNIERE_BLACKBOX_MAILGUN_DOMAIN=BLACKBOX_MAILGUN_DOMAIN,
|
||||||
TURNIERE_E2E_BASE_URL=BLACKBOX_INTERNAL_BASE_URL,
|
TURNIERE_E2E_BASE_URL=BLACKBOX_INTERNAL_BASE_URL,
|
||||||
|
TURNIERE_E2E_EXPECT_PROFILING=expect_profiling,
|
||||||
TURNIERE_E2E_EMAIL=E2E_EMAIL,
|
TURNIERE_E2E_EMAIL=E2E_EMAIL,
|
||||||
TURNIERE_E2E_PASSWORD=E2E_PASSWORD,
|
TURNIERE_E2E_PASSWORD=E2E_PASSWORD,
|
||||||
TURNIERE_E2E_USERNAME=E2E_USERNAME,
|
TURNIERE_E2E_USERNAME=E2E_USERNAME,
|
||||||
|
|
@ -137,6 +189,15 @@ def _format_command(command):
|
||||||
return " ".join(shlex.quote(part) for part in command)
|
return " ".join(shlex.quote(part) for part in command)
|
||||||
|
|
||||||
|
|
||||||
|
def _rspec_command(*paths):
|
||||||
|
command = ["bundle", "exec", "rspec", *paths]
|
||||||
|
if os.environ.get("CI"):
|
||||||
|
command.extend(["--format", "documentation", "--no-color"])
|
||||||
|
else:
|
||||||
|
command.extend(["--format", "progress", "--tty"])
|
||||||
|
return command
|
||||||
|
|
||||||
|
|
||||||
def _compose_run(compose_args, env, capture_output=False, check=True):
|
def _compose_run(compose_args, env, capture_output=False, check=True):
|
||||||
command = _docker_compose_cmd() + compose_args
|
command = _docker_compose_cmd() + compose_args
|
||||||
return _run_subprocess(command, env=env, capture_output=capture_output, check=check)
|
return _run_subprocess(command, env=env, capture_output=capture_output, check=check)
|
||||||
|
|
@ -248,6 +309,7 @@ def _run_blackbox_rspec(base_url, email, password, username, alt_email=E2E_ALT_E
|
||||||
alt_username=E2E_ALT_USERNAME):
|
alt_username=E2E_ALT_USERNAME):
|
||||||
env = _env(
|
env = _env(
|
||||||
TURNIERE_E2E_BASE_URL=base_url,
|
TURNIERE_E2E_BASE_URL=base_url,
|
||||||
|
TURNIERE_E2E_EXPECT_PROFILING="false",
|
||||||
TURNIERE_E2E_EMAIL=email,
|
TURNIERE_E2E_EMAIL=email,
|
||||||
TURNIERE_E2E_PASSWORD=password,
|
TURNIERE_E2E_PASSWORD=password,
|
||||||
TURNIERE_E2E_USERNAME=username,
|
TURNIERE_E2E_USERNAME=username,
|
||||||
|
|
@ -255,13 +317,9 @@ def _run_blackbox_rspec(base_url, email, password, username, alt_email=E2E_ALT_E
|
||||||
TURNIERE_E2E_ALT_PASSWORD=alt_password,
|
TURNIERE_E2E_ALT_PASSWORD=alt_password,
|
||||||
TURNIERE_E2E_ALT_USERNAME=alt_username,
|
TURNIERE_E2E_ALT_USERNAME=alt_username,
|
||||||
)
|
)
|
||||||
command = ["bundle", "exec", "rspec", "spec/e2e/http", "--format", "documentation"]
|
command = _rspec_command("spec/e2e/http")
|
||||||
print(_format_command(command))
|
print(_format_command(command))
|
||||||
result = _run_subprocess(command, env=env, capture_output=True, check=False)
|
result = _stream_subprocess(command, env=env, 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}")
|
report = _parse_rspec_report(f"{result.stdout}\n{result.stderr}")
|
||||||
_print_header("Blackbox report")
|
_print_header("Blackbox report")
|
||||||
|
|
@ -326,6 +384,21 @@ def db_migrate(c, env=None):
|
||||||
c.run(f"RAILS_ENV={_rails_env(env)} {_bundle_exec('rails', 'db:migrate')}", pty=True)
|
c.run(f"RAILS_ENV={_rails_env(env)} {_bundle_exec('rails', 'db:migrate')}", pty=True)
|
||||||
|
|
||||||
|
|
||||||
|
@task(name="db-reset")
|
||||||
|
def db_reset(c, env="test"):
|
||||||
|
"""Delete the local SQLite database for the requested non-production Rails env."""
|
||||||
|
rails_env = _rails_env(env)
|
||||||
|
if rails_env == "production":
|
||||||
|
raise ValueError("db-reset refuses to run in production")
|
||||||
|
|
||||||
|
deleted_path = _delete_sqlite_db(rails_env)
|
||||||
|
if deleted_path is None:
|
||||||
|
print(f"No local SQLite database configured for RAILS_ENV={rails_env}. Nothing deleted.")
|
||||||
|
return
|
||||||
|
|
||||||
|
print(f"Deleted {deleted_path}")
|
||||||
|
|
||||||
|
|
||||||
@task
|
@task
|
||||||
def server(c, host=SERVER_HOST, port=SERVER_PORT, env=None):
|
def server(c, host=SERVER_HOST, port=SERVER_PORT, env=None):
|
||||||
"""Start the backend locally in development."""
|
"""Start the backend locally in development."""
|
||||||
|
|
@ -348,10 +421,35 @@ def server_test(c, host=SERVER_HOST, port=SERVER_PORT):
|
||||||
server(c, host=host, port=port, env="test")
|
server(c, host=host, port=port, env="test")
|
||||||
|
|
||||||
|
|
||||||
|
@task(name="start-test-local")
|
||||||
|
def start_test_local(
|
||||||
|
c,
|
||||||
|
host=SERVER_HOST,
|
||||||
|
port=SERVER_PORT,
|
||||||
|
bootstrap_user=True,
|
||||||
|
reset_db=True,
|
||||||
|
email=E2E_EMAIL,
|
||||||
|
password=E2E_PASSWORD,
|
||||||
|
username=E2E_USERNAME,
|
||||||
|
alt_email=E2E_ALT_EMAIL,
|
||||||
|
alt_password=E2E_ALT_PASSWORD,
|
||||||
|
alt_username=E2E_ALT_USERNAME,
|
||||||
|
):
|
||||||
|
"""Install deps, recreate the local test DB, bootstrap HTTP E2E users, and start the test server."""
|
||||||
|
setup(c)
|
||||||
|
if reset_db:
|
||||||
|
db_reset(c, env="test")
|
||||||
|
db_migrate(c, env="test")
|
||||||
|
if bootstrap_user:
|
||||||
|
bootstrap_e2e_user(c, email=email, password=password, username=username)
|
||||||
|
bootstrap_e2e_user(c, email=alt_email, password=alt_password, username=alt_username)
|
||||||
|
server(c, host=host, port=port, env="test")
|
||||||
|
|
||||||
|
|
||||||
@task
|
@task
|
||||||
def test(c):
|
def test(c):
|
||||||
"""Run the normal RSpec suite."""
|
"""Run the normal RSpec suite."""
|
||||||
c.run(_bundle_exec("rspec"), pty=True)
|
_stream_subprocess(_rspec_command(), env=_env())
|
||||||
|
|
||||||
|
|
||||||
@task(name="test-shard")
|
@task(name="test-shard")
|
||||||
|
|
@ -383,9 +481,9 @@ def test_shard(c, node_index=1, node_total=8, dry_run=False):
|
||||||
if not files:
|
if not files:
|
||||||
return
|
return
|
||||||
|
|
||||||
c.run(
|
_stream_subprocess(
|
||||||
_bundle_exec("rspec") + " --require ./spec/rails_helper " + " ".join(shlex.quote(path) for path in files),
|
_rspec_command("--require", "./spec/rails_helper", *files),
|
||||||
pty=True,
|
env=_env(),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -466,8 +564,11 @@ def verify_http(
|
||||||
alt_email=E2E_ALT_EMAIL,
|
alt_email=E2E_ALT_EMAIL,
|
||||||
alt_password=E2E_ALT_PASSWORD,
|
alt_password=E2E_ALT_PASSWORD,
|
||||||
alt_username=E2E_ALT_USERNAME,
|
alt_username=E2E_ALT_USERNAME,
|
||||||
|
reset_db=True,
|
||||||
):
|
):
|
||||||
"""Boot a local test server, then run the HTTP E2E suite against it."""
|
"""Boot a local test server, then run the HTTP E2E suite against it."""
|
||||||
|
if reset_db:
|
||||||
|
db_reset(c, env="test")
|
||||||
db_migrate(c, env="test")
|
db_migrate(c, env="test")
|
||||||
bootstrap_e2e_user(c, email=email, password=password, username=username)
|
bootstrap_e2e_user(c, email=email, password=password, username=username)
|
||||||
bootstrap_e2e_user(c, email=alt_email, password=alt_password, username=alt_username)
|
bootstrap_e2e_user(c, email=alt_email, password=alt_password, username=alt_username)
|
||||||
|
|
@ -567,21 +668,41 @@ def scenario_playoff(
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@task(name="scenario-render-profile")
|
||||||
|
def scenario_render_profile(
|
||||||
|
c,
|
||||||
|
group_count=8,
|
||||||
|
teams_per_group=4,
|
||||||
|
playoff_teams_amount=8,
|
||||||
|
base_url=TEST_BASE_URL,
|
||||||
|
email=E2E_EMAIL,
|
||||||
|
password=E2E_PASSWORD,
|
||||||
|
username=E2E_USERNAME,
|
||||||
|
stop_at="profiled_show",
|
||||||
|
):
|
||||||
|
"""Materialize a group-stage tournament over HTTP and print tournament show profiling."""
|
||||||
|
c.run(
|
||||||
|
"ruby script/e2e_scenarios.rb group_stage_render_profile "
|
||||||
|
f"--group-count {group_count} "
|
||||||
|
f"--teams-per-group {teams_per_group} "
|
||||||
|
f"--playoff-teams-amount {playoff_teams_amount} "
|
||||||
|
f"--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")
|
@task(name="docker-build-production")
|
||||||
def docker_build_production(c, tag=PRODUCTION_TAG):
|
def docker_build_production(c, tag=PRODUCTION_TAG):
|
||||||
"""Build the production Docker image."""
|
"""Build the production Docker image."""
|
||||||
c.run(f"docker build -t {tag} -f docker/production/Dockerfile .", pty=True)
|
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")
|
@task(name="docker-build-test")
|
||||||
def docker_build_test(c, tag=TEST_TAG):
|
def docker_build_test(c, tag=TEST_TAG):
|
||||||
"""Build the test Docker image."""
|
"""Build the test Docker image."""
|
||||||
|
|
@ -590,9 +711,8 @@ def docker_build_test(c, tag=TEST_TAG):
|
||||||
|
|
||||||
@task(name="docker-build-all")
|
@task(name="docker-build-all")
|
||||||
def docker_build_all(c):
|
def docker_build_all(c):
|
||||||
"""Build the production, development, and test Docker images."""
|
"""Build the production and test Docker images."""
|
||||||
docker_build_production(c)
|
docker_build_production(c)
|
||||||
docker_build_development(c)
|
|
||||||
docker_build_test(c)
|
docker_build_test(c)
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -614,6 +734,7 @@ def docker_blackbox_up(
|
||||||
app_image=app_image,
|
app_image=app_image,
|
||||||
runner_image=runner_image,
|
runner_image=runner_image,
|
||||||
postgres_image=postgres_image,
|
postgres_image=postgres_image,
|
||||||
|
expect_profiling="false",
|
||||||
)
|
)
|
||||||
|
|
||||||
_print_header("Starting production blackbox stack")
|
_print_header("Starting production blackbox stack")
|
||||||
|
|
@ -680,6 +801,7 @@ def docker_blackbox_test(
|
||||||
app_image=app_image,
|
app_image=app_image,
|
||||||
runner_image=runner_image,
|
runner_image=runner_image,
|
||||||
postgres_image=postgres_image,
|
postgres_image=postgres_image,
|
||||||
|
expect_profiling="false",
|
||||||
)
|
)
|
||||||
|
|
||||||
_print_header("Running HTTP E2E against production image")
|
_print_header("Running HTTP E2E against production image")
|
||||||
|
|
@ -764,6 +886,7 @@ def blackbox_production(
|
||||||
app_image=app_image,
|
app_image=app_image,
|
||||||
runner_image=runner_image,
|
runner_image=runner_image,
|
||||||
postgres_image=postgres_image,
|
postgres_image=postgres_image,
|
||||||
|
expect_profiling="false",
|
||||||
)
|
)
|
||||||
|
|
||||||
exit_error = None
|
exit_error = None
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue