Merge branch 'master' into 'codex/tur-55-tur-56-timer-websocket'

# Conflicts:
#   e2e/lib/action_cable_client.rb
#   tasks.py
This commit is contained in:
Daniel Schädler 2026-04-23 21:57:14 +00:00
commit b0bbbb8ce5
36 changed files with 1228 additions and 157 deletions

View File

@ -6,6 +6,8 @@ stages:
variables: variables:
DOCKER_IMAGE_ENVS: "production test" DOCKER_IMAGE_ENVS: "production test"
DOCKERFILE_PATH: "Dockerfile"
DOCKER_BUILD_TARGETS: "production=production test=test"
include: include:
- project: 'turniere/turniere-infra' - project: 'turniere/turniere-infra'
@ -13,10 +15,10 @@ include:
rails spec: rails spec:
stage: test stage: test
needs:
- job: build_image
tags: tags:
- docker - docker
needs:
- job: build_image
image: $CI_REGISTRY_IMAGE/test/commits:$CI_COMMIT_SHA image: $CI_REGISTRY_IMAGE/test/commits:$CI_COMMIT_SHA
parallel: parallel:
matrix: matrix:
@ -43,10 +45,10 @@ rails spec:
rails e2e spec: rails e2e spec:
stage: e2e stage: e2e
needs:
- job: build_image
tags: tags:
- docker - docker
needs:
- job: build_image
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
@ -96,10 +98,10 @@ rails e2e spec:
rails follow sync e2e spec: rails follow sync e2e spec:
stage: e2e stage: e2e
needs:
- job: build_image
tags: tags:
- docker - docker
needs:
- job: build_image
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

View File

@ -176,6 +176,7 @@ Practical rule:
For team action lists specifically: For team action lists specifically:
- create lists explicitly through owner-facing HTTP API; do not auto-generate them from stage transitions unless product requirements say so
- subscribe by tournament id - subscribe by tournament id
- channel name is `TournamentTeamActionListsChannel` - channel name is `TournamentTeamActionListsChannel`
- broadcast full current list snapshot for that tournament - broadcast full current list snapshot for that tournament

105
Dockerfile Normal file
View File

@ -0,0 +1,105 @@
# syntax=docker/dockerfile:1.7
ARG RUBY_VERSION=3.1.2
ARG BUNDLER_VERSION=2.3.13
FROM ruby:${RUBY_VERSION}-slim AS runtime-base
ARG BUNDLER_VERSION
ENV LANG=C.UTF-8 \
BUNDLE_JOBS=4 \
BUNDLE_RETRY=3 \
BUNDLE_PATH=/app/vendor/bundle \
BUNDLE_APP_CONFIG=/app/vendor/bundle
WORKDIR /app
RUN apt-get update -qq \
&& apt-get install -y --no-install-recommends \
libpq5 \
nodejs \
openssl \
&& rm -rf /var/lib/apt/lists/* \
&& gem install bundler -v ${BUNDLER_VERSION} \
&& gem install tzinfo-data
FROM runtime-base AS build-base
RUN apt-get update -qq \
&& apt-get install -y --no-install-recommends \
build-essential \
git \
libpq-dev \
libsqlite3-dev \
pkg-config \
python3 \
python3-pip \
&& rm -rf /var/lib/apt/lists/*
COPY Gemfile Gemfile.lock ./
FROM build-base AS bundle-production
RUN bundle config set deployment 'true' \
&& bundle config set without 'development test' \
&& bundle install \
&& rm -rf /usr/local/bundle/cache/*.gem
FROM build-base AS bundle-test
RUN bundle config set with 'development test' \
&& bundle install \
&& rm -rf /usr/local/bundle/cache/*.gem
FROM runtime-base AS production
ENV RAILS_ENV=production
COPY --from=bundle-production /usr/local/bundle /usr/local/bundle
COPY --from=bundle-production /app/vendor/bundle /app/vendor/bundle
COPY Gemfile Gemfile.lock /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
COPY config/environments/production.rb /app/config/environments/beta.rb
EXPOSE 3000
CMD ["sh", "-lc", "bundle exec rails db:migrate && bundle exec rails s -p 3000 -b 0.0.0.0"]
FROM runtime-base AS test
ENV RAILS_ENV=test
RUN apt-get update -qq \
&& apt-get install -y --no-install-recommends \
libsqlite3-0 \
python3 \
python3-pip \
&& rm -rf /var/lib/apt/lists/* \
&& python3 -m pip install --no-cache-dir invoke
COPY --from=bundle-test /usr/local/bundle /usr/local/bundle
COPY --from=bundle-test /app/vendor/bundle /app/vendor/bundle
COPY Gemfile Gemfile.lock /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/
COPY config/environments/production.rb /app/config/environments/beta.rb
COPY tasks.py /app/tasks.py
COPY e2e /app/e2e
COPY lib /app/lib
COPY spec /app/spec
COPY .rspec /app/.rspec
CMD ["sh", "-lc", "bundle exec rails db:migrate && bundle exec rails spec"]

View File

@ -30,6 +30,7 @@ bundle exec rspec
Leader/follower tournament sync docs: Leader/follower tournament sync docs:
- [doc/leader_follower.md](doc/leader_follower.md) - [doc/leader_follower.md](doc/leader_follower.md)
- [doc/beamer_live_updates.md](doc/beamer_live_updates.md)
## Docker ## Docker
[Registry](https://gitlab.com/turniere/turniere-backend/container_registry) [Registry](https://gitlab.com/turniere/turniere-backend/container_registry)

View File

@ -0,0 +1,20 @@
# frozen_string_literal: true
class TournamentBeamersChannel < ApplicationCable::Channel
class << self
def stream_name(tournament_id:, beamer_name:)
"tournament_#{tournament_id}:beamers:#{beamer_name}"
end
end
def subscribed
tournament = Tournament.find(params[:tournament_id])
beamer_name = params[:beamer_name].presence
beamer = tournament.beamers.find_by(name: beamer_name) || tournament.beamers.find_by(name: Beamer::DEFAULT_NAME)
return reject unless beamer
stream_from self.class.stream_name(tournament_id: tournament.id, beamer_name: beamer.name)
transmit BeamerBroadcastPayload.build(beamer:, event: 'snapshot')
end
end

View File

@ -65,11 +65,8 @@ class StagesController < ApplicationController
raise ActiveRecord::Rollback raise ActiveRecord::Rollback
end end
return unless AddPlayoffsToTournamentAndSave.call(tournament: @stage.tournament, return if AddPlayoffsToTournamentAndSave.call(tournament: @stage.tournament,
teams: GroupStageService.get_advancing_teams(@stage)).success? teams: GroupStageService.get_advancing_teams(@stage)).success?
TeamActionListService.ensure_group_stage_survivor_playoff_token_list!(@stage.tournament)
return
render json: { error: 'Generating group stage failed' }, status: :unprocessable_entity render json: { error: 'Generating group stage failed' }, status: :unprocessable_entity
raise ActiveRecord::Rollback raise ActiveRecord::Rollback

View File

@ -5,6 +5,7 @@ class TeamActionItemsController < ApplicationController
before_action :authenticate_user!, only: %i[update] before_action :authenticate_user!, only: %i[update]
before_action -> { require_owner! @team_action_item.owner }, only: %i[update] before_action -> { require_owner! @team_action_item.owner }, only: %i[update]
before_action -> { require_writable_tournament!(@team_action_item.tournament) }, only: %i[update] before_action -> { require_writable_tournament!(@team_action_item.tournament) }, only: %i[update]
rescue_from ActiveRecord::RecordNotFound, with: :render_not_found_error
# PATCH /team_action_items/1 # PATCH /team_action_items/1
def update def update

View File

@ -2,10 +2,28 @@
class TeamActionListsController < ApplicationController class TeamActionListsController < ApplicationController
before_action :set_tournament before_action :set_tournament
before_action :authenticate_user!, only: %i[update_item] before_action :authenticate_user!, only: %i[create update_item]
before_action -> { require_owner! @tournament.owner }, only: %i[update_item] before_action -> { require_owner! @tournament.owner }, only: %i[create update_item]
before_action -> { require_writable_tournament!(@tournament) }, only: %i[update_item] before_action -> { require_writable_tournament!(@tournament) }, only: %i[create update_item]
before_action :set_team_action_item, only: %i[update_item] before_action :set_team_action_item, only: %i[update_item]
rescue_from ActiveRecord::RecordNotFound, with: :render_not_found_error
# POST /tournaments/:tournament_id/team_action_lists
def create
team_action_list = TeamActionListService.create!(
tournament: @tournament,
name: team_action_list_params.fetch(:name),
key: team_action_list_params.fetch(:key),
action_name: team_action_list_params.fetch(:action_name),
source: team_action_list_params.fetch(:source)
)
render json: team_action_list, status: :created
rescue TeamActionListService::InvalidSourceError, TeamActionListService::EmptySourceError => e
render json: { error: e.message }, status: :unprocessable_entity
rescue ActiveRecord::RecordInvalid => e
render json: e.record.errors, status: :unprocessable_entity
end
# PATCH /tournaments/:tournament_id/team_action_lists/:key/teams/:team_id # PATCH /tournaments/:tournament_id/team_action_lists/:key/teams/:team_id
def update_item def update_item
@ -33,4 +51,8 @@ class TeamActionListsController < ApplicationController
def team_action_item_params def team_action_item_params
params.slice(:completed).permit(:completed) params.slice(:completed).permit(:completed)
end end
def team_action_list_params
params.permit(:name, :key, :action_name, :source)
end
end end

View File

@ -13,4 +13,37 @@ class Beamer < ApplicationRecord
validates :display_state, presence: true, inclusion: { in: DISPLAY_STATES } validates :display_state, presence: true, inclusion: { in: DISPLAY_STATES }
delegate :owner, to: :tournament delegate :owner, to: :tournament
before_update :store_previous_name_for_broadcast, if: :will_save_change_to_name?
before_destroy :store_snapshot_for_broadcast
after_commit :broadcast_state_change, on: %i[create update destroy]
private
def store_previous_name_for_broadcast
@broadcast_previous_name = name_in_database
end
def store_snapshot_for_broadcast
@broadcast_snapshot = BeamerBroadcastPayload.snapshot(self)
end
def broadcast_state_change
snapshot = @broadcast_snapshot || BeamerBroadcastPayload.snapshot(self)
BeamerBroadcast.call(
tournament_id: snapshot.fetch(:tournament_id),
beamer_names: [@broadcast_previous_name, snapshot.fetch(:name)].compact.uniq,
payload: BeamerBroadcastPayload.build(snapshot: snapshot, event: broadcast_event)
)
ensure
@broadcast_previous_name = nil
@broadcast_snapshot = nil
end
def broadcast_event
return 'deleted' if destroyed?
previous_changes.key?('id') ? 'created' : 'updated'
end
end end

View File

@ -6,6 +6,7 @@ class TeamActionList < ApplicationRecord
validates :name, :key, :action_name, :source, presence: true validates :name, :key, :action_name, :source, presence: true
validates :key, uniqueness: { scope: :tournament_id } validates :key, uniqueness: { scope: :tournament_id }
validates :key, format: { with: /\A[a-z0-9_]+\z/, message: 'may only contain lowercase letters, numbers, and underscores' }
delegate :owner, to: :tournament delegate :owner, to: :tournament

View File

@ -1,5 +1,5 @@
# frozen_string_literal: true # frozen_string_literal: true
class BeamerSerializer < ApplicationSerializer class BeamerSerializer < ApplicationSerializer
attributes :name, :display_state, :qr_code_url, :logo_url attributes :id, :name, :display_state, :qr_code_url, :logo_url
end end

View File

@ -0,0 +1,14 @@
# frozen_string_literal: true
class BeamerBroadcast
class << self
def call(tournament_id:, beamer_names:, payload:)
Array(beamer_names).compact.uniq.each do |beamer_name|
ActionCable.server.broadcast(
TournamentBeamersChannel.stream_name(tournament_id:, beamer_name:),
payload
)
end
end
end
end

View File

@ -0,0 +1,39 @@
# frozen_string_literal: true
class BeamerBroadcastPayload
class << self
def build(beamer: nil, snapshot: nil, event:)
data = snapshot || self.snapshot(beamer)
{
type: "beamer.#{event}",
event: event,
tournament_id: data.fetch(:tournament_id),
beamer: serialized_beamer(data)
}
end
def snapshot(beamer)
{
id: beamer.id,
tournament_id: beamer.tournament_id,
name: beamer.name,
display_state: beamer.display_state,
qr_code_url: beamer.qr_code_url,
logo_url: beamer.logo_url
}
end
private
def serialized_beamer(snapshot)
{
id: snapshot.fetch(:id),
name: snapshot.fetch(:name),
display_state: snapshot.fetch(:display_state),
qr_code_url: snapshot[:qr_code_url],
logo_url: snapshot[:logo_url]
}
end
end
end

View File

@ -1,34 +1,48 @@
# frozen_string_literal: true # frozen_string_literal: true
class TeamActionListService class TeamActionListService
GROUP_STAGE_SURVIVOR_PLAYOFF_TOKEN_KEY = 'group_stage_survivor_playoff_tokens' SUPPORTED_SOURCES = %w[all_tournament_teams group_stage_survivors].freeze
class InvalidSourceError < StandardError; end
class EmptySourceError < StandardError; end
class << self class << self
def ensure_group_stage_survivor_playoff_token_list!(tournament) def create!(tournament:, name:, key:, action_name:, source:)
group_stage = tournament.group_stage teams = teams_for_source!(tournament:, source:)
return nil unless group_stage&.finished? raise EmptySourceError, "Source #{source} does not currently contain any teams" if teams.empty?
teams = tournament.teams_advancing_from_group_stage list = nil
return nil if teams.empty? TeamActionList.transaction do
list = tournament.team_action_lists.new(
name:,
key:,
action_name:,
source:
)
list.save!
list.sync_teams!(teams)
end
ensure_list!( TournamentSyncEnqueue.call(tournament)
tournament: tournament, list
key: GROUP_STAGE_SURVIVOR_PLAYOFF_TOKEN_KEY,
name: 'Playoff token collection',
action_name: 'Collect playoff token',
source: 'group_stage_survivors',
teams: teams
)
end end
def ensure_list!(tournament:, key:, name:, action_name:, source:, teams:) def source_keys
list = tournament.team_action_lists.find_or_initialize_by(key: key) SUPPORTED_SOURCES
list.name = name end
list.action_name = action_name
list.source = source def teams_for_source!(tournament:, source:)
list.save! case source
list.sync_teams!(teams) when 'all_tournament_teams'
list tournament.teams.to_a
when 'group_stage_survivors'
group_stage = tournament.group_stage
return [] unless group_stage&.finished?
tournament.teams_advancing_from_group_stage
else
raise InvalidSourceError, "Unsupported team action list source: #{source}"
end
end end
end end
end end

View File

@ -21,6 +21,7 @@ Rails.application.routes.draw do
resources :teams, only: %i[show update] resources :teams, only: %i[show update]
resources :team_action_items, only: %i[update] resources :team_action_items, only: %i[update]
resources :tournaments do resources :tournaments do
resources :team_action_lists, only: %i[create]
patch 'team_action_lists/:key/teams/:team_id', to: 'team_action_lists#update_item' patch 'team_action_lists/:key/teams/:team_id', to: 'team_action_lists#update_item'
resources :statistics, only: %i[index] resources :statistics, only: %i[index]
resources :matches, only: %i[index] resources :matches, only: %i[index]

View File

@ -0,0 +1,64 @@
# Beamer Live Updates
Beamer/projector live updates are backend-owned and use Rails ActionCable at `/cable`.
## Goal
Frontend fullscreen pages talk only to `turniere-backend`.
No `TURNIERE_MATCH_URL`.
No Socket.IO dependency on `turniere-match`.
## Transport
- websocket endpoint: `GET /cable`
- ActionCable channel: `TournamentBeamersChannel`
- subscription params:
- `tournament_id`
- `beamer_name`
If requested beamer does not exist, channel falls back to tournament default beamer when present.
## Payload contract
Initial subscribe snapshot:
```json
{
"type": "beamer.snapshot",
"event": "snapshot",
"tournament_id": 12,
"beamer": {
"id": 34,
"name": "default",
"display_state": "matches_all",
"qr_code_url": null,
"logo_url": null
}
}
```
Mutation events use same `beamer` shape with `type` / `event` values:
- `beamer.created`
- `beamer.updated`
- `beamer.deleted`
## Broadcast source
Broadcasts come from `Beamer` model commit callbacks.
That means normal HTTP writes and follower-sync imports use same broadcast path.
Covered mutations:
- create
- update
- delete
## Frontend expectation
Frontend should:
- fetch beamer config from backend REST API
- subscribe to `TournamentBeamersChannel`
- update fullscreen state from explicit `beamer` payload
- reconnect if beamer name changes or current beamer gets deleted

View File

@ -0,0 +1,61 @@
# Buildx Migration Notes
This backend branch temporarily pins the shared GitLab include to the infra branch
`codex/buildx-multiarch-template` so the new shared image build flow can be tested
end to end before the infra MR is merged.
After the infra MR is merged:
- remove the temporary `ref:` override from `.gitlab-ci.yml`
- keep the repo-level variables that describe how this repo maps images to a single
multi-stage `Dockerfile`
## Backend pattern
Backend now uses:
- one root `Dockerfile`
- `DOCKER_IMAGE_ENVS: "production test"`
- `DOCKERFILE_PATH: "Dockerfile"`
- `DOCKER_BUILD_TARGETS: "production=production test=test"`
- shared infra template builds amd64 automatically on push pipelines
- shared infra template builds arm64 manually on branch pipelines and automatically on default-branch pushes
## turniere-frontend
Frontend can move in two steps.
1. No Dockerfile restructuring required for the shared buildx rollout itself.
Its current `docker/production/Dockerfile` already fits the shared template.
2. To add Raspberry Pi support, verify the current production image really builds on
`linux/arm64`.
The current file uses `node:16-alpine` and `alpine`; that should be checked in CI.
Recommended frontend CI change after infra is merged:
- keep `DOCKER_IMAGE_ENVS: "production"`
- no extra platform variable needed if default shared behavior is acceptable
Optional frontend follow-up:
- if you want the same repo shape as backend, replace `docker/production/Dockerfile`
with a root multi-stage `Dockerfile`, then add:
`DOCKERFILE_PATH: "Dockerfile"` and
`DOCKER_BUILD_TARGETS: "production=production"`
## turniere-match
Match also does not need a Dockerfile restructuring for the shared buildx rollout.
Its current `docker/production/Dockerfile` already matches the default shared
template contract.
Recommended match CI change after infra is merged:
- keep `DOCKER_IMAGE_ENVS: "production"`
- no extra platform variable needed if default shared behavior is acceptable
Suggested follow-up:
- run a multi-arch build smoke test for the Alpine-based Python image
- if packaging or native wheels become slow on arm64, consider a multi-stage
Dockerfile with a slimmer runtime image similar to the backend pattern

View File

@ -2,13 +2,19 @@
## Purpose ## Purpose
Team action lists let the backend expose checklist-style workflows for teams inside a tournament. Team action lists let tournament owners create checklist-style workflows for teams inside a tournament.
Current example: Examples:
- `group_stage_survivor_playoff_tokens` - collect playoff tokens
- track team check-in
- hand out medals
That list contains all teams that advanced from the group stage and tracks whether each team already collected a playoff token. Important:
- lists are not auto-generated by backend tournament events
- owner creates a list explicitly through API
- backend materializes the initial team items from the chosen `source`
## Read Flow ## Read Flow
@ -26,7 +32,7 @@ Example response shape:
{ {
"id": 9, "id": 9,
"name": "Playoff token collection", "name": "Playoff token collection",
"key": "group_stage_survivor_playoff_tokens", "key": "playoff_token_collection",
"action_name": "Collect playoff token", "action_name": "Collect playoff token",
"source": "group_stage_survivors", "source": "group_stage_survivors",
"team_action_items": [ "team_action_items": [
@ -45,6 +51,50 @@ Example response shape:
} }
``` ```
## Create Flow
Only tournament owner may create lists.
Follower tournaments in `read_only_mode` reject creation with `423`.
Endpoint:
- `POST /tournaments/:tournament_id/team_action_lists`
Request body:
```json
{
"name": "Playoff token collection",
"key": "playoff_token_collection",
"action_name": "Collect playoff token",
"source": "group_stage_survivors"
}
```
Response:
- `201 Created`
- response body is the created `TeamActionList` including `team_action_items`
Current supported sources:
- `all_tournament_teams`
- `group_stage_survivors`
Current source behavior:
- `all_tournament_teams` uses all teams currently in tournament
- `group_stage_survivors` uses teams currently advancing from a finished group stage
Creation is snapshot-style:
- backend resolves teams at create time
- backend creates one item per resolved team
- backend does not auto-create lists later
- backend does not auto-refresh existing list membership later
If selected source currently resolves to no teams, backend returns `422`.
## Write Flow ## Write Flow
Only tournament owner may change item status. Only tournament owner may change item status.
@ -79,7 +129,7 @@ Endpoint:
Example: Example:
- `PATCH /tournaments/123/team_action_lists/group_stage_survivor_playoff_tokens/teams/55` - `PATCH /tournaments/123/team_action_lists/playoff_token_collection/teams/55`
Request body: Request body:
@ -93,7 +143,12 @@ Response body for both write endpoints is the updated `TeamActionItem`.
## Idempotency Rules ## Idempotency Rules
These endpoints are designed to be idempotent if frontend sends explicit target state. Create endpoint is idempotent by unique business key per tournament:
- backend enforces unique `(tournament_id, key)`
- repeated create with same key returns `422`, not a second list
Item update endpoints are idempotent if frontend sends explicit target state.
Good: Good:
@ -103,12 +158,12 @@ Good:
Avoid: Avoid:
- toggle-style client behavior such as "invert current state" - toggle-style client behavior such as "invert current state"
- whole-list writeback from stale client state
Why this is idempotent: Why item updates are idempotent:
- sending `completed=true` multiple times leaves item in same final state - sending `completed=true` multiple times leaves item in same final state
- sending `completed=false` multiple times leaves item in same final state - sending `completed=false` multiple times leaves item in same final state
- backend does not create duplicate items
- backend stores one unique item per `(team_action_list_id, team_id)` - backend stores one unique item per `(team_action_list_id, team_id)`
`completed_at` behavior: `completed_at` behavior:
@ -122,10 +177,10 @@ Why this is idempotent:
Recommended frontend flow: Recommended frontend flow:
1. Load tournament via `GET /tournaments/:id` 1. Load tournament via `GET /tournaments/:id`
2. Find desired list by stable `key` 2. Render current `team_action_lists`
3. Render `team_action_items` 3. If owner wants a new checklist, send `POST /tournaments/:id/team_action_lists`
4. On user action, send explicit target state 4. For item changes, send explicit target state for one item only
5. Update local UI from response or refetch tournament 5. Update local UI from response or websocket snapshot
Recommended frontend identifiers: Recommended frontend identifiers:
@ -135,6 +190,7 @@ Recommended frontend identifiers:
Recommended optimistic UI: Recommended optimistic UI:
- create list only once per intended key
- set checkbox immediately to requested target state - set checkbox immediately to requested target state
- revert if API returns error - revert if API returns error
- do not compute next state from stale cached data if multiple clients may edit - do not compute next state from stale cached data if multiple clients may edit
@ -143,11 +199,13 @@ Recommended optimistic UI:
Expected statuses: Expected statuses:
- `200` update accepted - `201` list created
- `200` item update accepted
- `401` unauthenticated
- `403` authenticated but not tournament owner - `403` authenticated but not tournament owner
- `404` tournament, list, team, or item not found - `404` tournament, list, team, or item not found
- `422` invalid create payload, duplicate key, unsupported source, or source with no teams
- `423` follower tournament is read-only - `423` follower tournament is read-only
- `401` unauthenticated
## Sync / Follower Behavior ## Sync / Follower Behavior
@ -155,10 +213,12 @@ Team action lists are included in tournament sync snapshots.
That means: That means:
- source tournament owner updates item state - source owner creates list on source tournament
- backend enqueues tournament sync - backend enqueues tournament sync
- follower tournament receives updated list state - follower tournament receives created list and all items
- follower users can view but not mutate item state while read-only - source owner updates item state
- follower tournament receives updated completion state
- follower users can view but not mutate list state while read-only
## Websocket / Live Update Behavior ## Websocket / Live Update Behavior
@ -168,7 +228,7 @@ This is a websocket flow, not a webhook flow.
- backend pushes updates to subscribed clients over websocket - backend pushes updates to subscribed clients over websocket
- frontend does not register callback URLs - frontend does not register callback URLs
- writes still happen over normal HTTP `PATCH` requests - writes still happen over normal HTTP `POST` and `PATCH` requests
Endpoint: Endpoint:
@ -190,11 +250,12 @@ Subscription identifier example:
Behavior: Behavior:
- subscription immediately receives current full team-action-list snapshot for that tournament - subscription immediately receives current full team-action-list snapshot for that tournament
- later list/item changes broadcast updated full snapshot again - later list creation and item changes broadcast updated full snapshot again
- same broadcast path is used for: - same broadcast path is used for:
- direct source updates - list creation
- direct item updates
- follower sync imports - follower sync imports
- both write endpoints trigger the same backend update path before broadcast: - both item update endpoints trigger the same backend update path before broadcast:
- `PATCH /team_action_items/:id` - `PATCH /team_action_items/:id`
- `PATCH /tournaments/:tournament_id/team_action_lists/:key/teams/:team_id` - `PATCH /tournaments/:tournament_id/team_action_lists/:key/teams/:team_id`
@ -208,7 +269,7 @@ Payload shape:
{ {
"id": 9, "id": 9,
"name": "Playoff token collection", "name": "Playoff token collection",
"key": "group_stage_survivor_playoff_tokens", "key": "playoff_token_collection",
"action_name": "Collect playoff token", "action_name": "Collect playoff token",
"source": "group_stage_survivors", "source": "group_stage_survivors",
"team_action_items": [ "team_action_items": [
@ -239,20 +300,22 @@ Practical frontend pattern:
1. load initial tournament state over HTTP 1. load initial tournament state over HTTP
2. open websocket subscription for same tournament id 2. open websocket subscription for same tournament id
3. render incoming websocket payload as latest live snapshot 3. render incoming websocket payload as latest live snapshot
4. when user checks or unchecks one team, send one idempotent HTTP `PATCH` for that item only 4. if owner submits create form, send one HTTP `POST` to create the list
5. let websocket keep other viewers in sync 5. when user checks or unchecks one team, send one idempotent HTTP `PATCH` for that item only
6. let websocket keep other viewers in sync
This avoids whole-list writebacks and reduces race-condition risk between multiple clients. This avoids whole-list writebacks and reduces race-condition risk between multiple clients.
## Future Extension Guidance ## Future Extension Guidance
When new lists are added, frontend should not hardcode backend internals beyond: When new sources or list types are added, frontend should not hardcode backend internals beyond:
- stable list `key` - stable list `key`
- user-facing `name` - user-facing `name`
- user-facing `action_name` - user-facing `action_name`
- selected `source`
New list types should automatically fit the same frontend rendering model: New list types should still fit same frontend rendering model:
- list metadata - list metadata
- list items - list items

View File

@ -1,19 +0,0 @@
ARG base_commit
FROM ruby:3.1.2-slim
ENV RAILS_ENV production
# RUN apk add --no-cache build-base tzdata sqlite sqlite-dev postgresql-dev git && gem install tzinfo-data
RUN apt-get update -qq && apt-get install -y openssl git build-essential libpq-dev nodejs && apt-get clean && gem install tzinfo-data
WORKDIR /app
COPY Gemfile* /app/
RUN gem install bundler:2.3.13 && bundle config set deployment 'true' && bundle install
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
COPY config/environments/production.rb config/environments/beta.rb
EXPOSE 3000
CMD bundle exec rails db:migrate && bundle exec rails s -p 3000 -b 0.0.0.0

View File

@ -1,13 +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 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
CMD bundle exec rails db:migrate && bundle exec rails spec

View File

@ -17,24 +17,54 @@ module TurniereE2E
end end
def subscribe_team_action_lists!(tournament_id:) def subscribe_team_action_lists!(tournament_id:)
subscribe!(channel: 'TournamentTeamActionListsChannel', tournament_id: tournament_id) identifier = {
channel: 'TournamentTeamActionListsChannel',
tournament_id: tournament_id
}.to_json
subscribe!(identifier:) do |message|
team_action_lists_payload_message?(message)
end
end end
def subscribe_timer!(tournament_id:) def subscribe_timer!(tournament_id:)
subscribe!(channel: 'TournamentTimersChannel', tournament_id: tournament_id) identifier = {
channel: 'TournamentTimersChannel',
tournament_id: tournament_id
}.to_json
subscribe!(identifier:) do |message|
timer_payload_message?(message)
end
end
def subscribe_beamer!(tournament_id:, beamer_name:)
identifier = {
channel: 'TournamentBeamersChannel',
tournament_id: tournament_id,
beamer_name: beamer_name
}.to_json
subscribe!(identifier:) do |message|
beamer_payload_message?(message)
end
end end
def wait_for_payload!(timeout: 20) def wait_for_payload!(timeout: 20)
wait_for!(timeout: timeout) do |message| wait_for!(timeout: timeout) do |message|
payload = message['message'] team_action_lists_payload_message?(message)
payload.is_a?(Hash) && payload['type'] == 'team_action_lists.updated'
end.fetch('message') end.fetch('message')
end end
def wait_for_timer_payload!(timeout: 20) def wait_for_timer_payload!(timeout: 20)
wait_for!(timeout: timeout) do |message| wait_for!(timeout: timeout) do |message|
payload = message['message'] timer_payload_message?(message)
payload.is_a?(Hash) && payload['type'] == 'timer.updated' end.fetch('message')
end
def wait_for_beamer_payload!(timeout: 20)
wait_for!(timeout: timeout) do |message|
beamer_payload_message?(message)
end.fetch('message') end.fetch('message')
end end
@ -60,23 +90,8 @@ module TurniereE2E
attr_reader :base_url, :socket, :driver attr_reader :base_url, :socket, :driver
def connect! def subscribe!(identifier:)
return unless @driver.nil?
@socket = build_socket
@driver = WebSocket::Driver.client(self, protocols: ACTION_CABLE_PROTOCOLS)
@driver.set_header('Origin', http_origin)
@driver.on(:message) { |event| @messages << JSON.parse(event.data) }
@driver.start
pump_until_open!
end
def subscribe!(channel:, tournament_id:)
connect! connect!
identifier = {
channel: channel,
tournament_id: tournament_id
}.to_json
driver.text( driver.text(
JSON.dump( JSON.dump(
@ -87,10 +102,21 @@ module TurniereE2E
handshake_message = wait_for! do |message| handshake_message = wait_for! do |message|
message['type'] == 'confirm_subscription' || message['type'] == 'confirm_subscription' ||
(message['identifier'] == identifier && message['message'].is_a?(Hash)) (message['identifier'] == identifier && yield(message))
end end
@messages << handshake_message if payload_message?(handshake_message) @messages << handshake_message if yield(handshake_message)
end
def connect!
return unless @driver.nil?
@socket = build_socket
@driver = WebSocket::Driver.client(self, protocols: ACTION_CABLE_PROTOCOLS)
@driver.set_header('Origin', http_origin)
@driver.on(:message) { |event| @messages << JSON.parse(event.data) }
@driver.start
pump_until_open!
end end
def build_socket def build_socket
@ -154,9 +180,19 @@ module TurniereE2E
nil nil
end end
def payload_message?(message) def team_action_lists_payload_message?(message)
payload = message['message'] payload = message['message']
payload.is_a?(Hash) && %w[team_action_lists.updated timer.updated].include?(payload['type']) payload.is_a?(Hash) && payload['type'] == 'team_action_lists.updated'
end
def timer_payload_message?(message)
payload = message['message']
payload.is_a?(Hash) && payload['type'] == 'timer.updated'
end
def beamer_payload_message?(message)
payload = message['message']
payload.is_a?(Hash) && payload['type'].to_s.start_with?('beamer.')
end end
end end
end end

View File

@ -92,14 +92,9 @@ RSpec.describe StagesController, type: :controller do
.to match_array(GroupStageService.get_advancing_teams(running_group_stage)) .to match_array(GroupStageService.get_advancing_teams(running_group_stage))
end end
it 'creates playoff token collection list for advancing teams' do it 'does not auto-create team action lists' do
tournament = running_group_stage.tournament.reload tournament = running_group_stage.tournament.reload
list = tournament.team_action_lists.find_by!(key: TeamActionListService::GROUP_STAGE_SURVIVOR_PLAYOFF_TOKEN_KEY) expect(tournament.team_action_lists).to be_empty
expect(list.name).to eq('Playoff token collection')
expect(list.team_action_items.includes(:team).map(&:team))
.to match_array(GroupStageService.get_advancing_teams(running_group_stage))
expect(list.team_action_items.pluck(:completed).uniq).to eq([false])
end end
end end

View File

@ -49,5 +49,19 @@ RSpec.describe TeamActionItemsController, type: :controller do
expect(response).to have_http_status(:forbidden) expect(response).to have_http_status(:forbidden)
end end
end end
context 'when item is missing' do
let(:owner) { create(:user) }
before do
apply_authentication_headers_for owner
end
it 'returns not found' do
patch :update, params: { id: 999_999_999, completed: true }
expect(response).to have_http_status(:not_found)
end
end
end end
end end

View File

@ -3,20 +3,113 @@
require 'rails_helper' require 'rails_helper'
RSpec.describe TeamActionListsController, type: :controller do RSpec.describe TeamActionListsController, type: :controller do
let(:tournament) { create(:tournament, teams_count: 4) }
let(:team_action_item) { create(:team_action_item) } let(:team_action_item) { create(:team_action_item) }
let(:tournament) { team_action_item.tournament }
let(:list) { team_action_item.team_action_list } let(:list) { team_action_item.team_action_list }
let(:team) { team_action_item.team } let(:team) { team_action_item.team }
describe 'PATCH #update_item' do describe 'POST #create' do
let(:create_params) do
{
tournament_id: tournament.to_param,
name: 'Playoff token collection',
key: 'playoff_token_collection',
action_name: 'Collect playoff token',
source: 'all_tournament_teams'
}
end
context 'as owner' do context 'as owner' do
before do before do
apply_authentication_headers_for tournament.owner apply_authentication_headers_for tournament.owner
end end
it 'creates a list populated from the selected source' do
post :create, params: create_params
expect(response).to have_http_status(:created)
created_list = tournament.team_action_lists.find_by!(key: 'playoff_token_collection')
expect(created_list.name).to eq('Playoff token collection')
expect(created_list.action_name).to eq('Collect playoff token')
expect(created_list.source).to eq('all_tournament_teams')
expect(created_list.team_action_items.includes(:team).map(&:team)).to match_array(tournament.teams)
end
it 'enqueues tournament sync after create' do
allow(TournamentSyncEnqueue).to receive(:call)
post :create, params: create_params
expect(TournamentSyncEnqueue).to have_received(:call).with(tournament)
end
it 'returns unprocessable entity for unsupported source' do
post :create, params: create_params.merge(source: 'unknown_source')
expect(response).to have_http_status(:unprocessable_entity)
expect(deserialize_response(response)[:error]).to eq('Unsupported team action list source: unknown_source')
end
it 'returns unprocessable entity when selected source resolves to no teams' do
empty_tournament = create(:tournament, teams_count: 0)
apply_authentication_headers_for empty_tournament.owner
post :create, params: create_params.merge(tournament_id: empty_tournament.to_param)
expect(response).to have_http_status(:unprocessable_entity)
expect(deserialize_response(response)[:error]).to eq('Source all_tournament_teams does not currently contain any teams')
end
it 'returns unprocessable entity for duplicate key' do
tournament.team_action_lists.create!(
name: 'Existing list',
key: 'playoff_token_collection',
action_name: 'Existing action',
source: 'all_tournament_teams'
)
post :create, params: create_params
expect(response).to have_http_status(:unprocessable_entity)
end
end
context 'as another user' do
before do
apply_authentication_headers_for create(:user)
end
it 'returns forbidden' do
post :create, params: create_params
expect(response).to have_http_status(:forbidden)
end
end
context 'for follower tournament' do
before do
tournament.update!(read_only_mode: true, sync_auth_token: 'sync-token')
apply_authentication_headers_for tournament.owner
end
it 'returns locked' do
post :create, params: create_params
expect(response).to have_http_status(:locked)
end
end
end
describe 'PATCH #update_item' do
context 'as owner' do
before do
apply_authentication_headers_for list.tournament.owner
end
it 'updates item by list key and team id' do it 'updates item by list key and team id' do
patch :update_item, params: { patch :update_item, params: {
tournament_id: tournament.to_param, tournament_id: list.tournament.to_param,
key: list.key, key: list.key,
team_id: team.to_param, team_id: team.to_param,
completed: true completed: true
@ -29,7 +122,7 @@ RSpec.describe TeamActionListsController, type: :controller do
it 'is idempotent when same state sent repeatedly' do it 'is idempotent when same state sent repeatedly' do
2.times do 2.times do
patch :update_item, params: { patch :update_item, params: {
tournament_id: tournament.to_param, tournament_id: list.tournament.to_param,
key: list.key, key: list.key,
team_id: team.to_param, team_id: team.to_param,
completed: true completed: true
@ -44,13 +137,13 @@ RSpec.describe TeamActionListsController, type: :controller do
allow(TournamentSyncEnqueue).to receive(:call) allow(TournamentSyncEnqueue).to receive(:call)
patch :update_item, params: { patch :update_item, params: {
tournament_id: tournament.to_param, tournament_id: list.tournament.to_param,
key: list.key, key: list.key,
team_id: team.to_param, team_id: team.to_param,
completed: true completed: true
} }
expect(TournamentSyncEnqueue).to have_received(:call).with(tournament) expect(TournamentSyncEnqueue).to have_received(:call).with(list.tournament)
end end
end end
@ -61,7 +154,7 @@ RSpec.describe TeamActionListsController, type: :controller do
it 'returns forbidden' do it 'returns forbidden' do
patch :update_item, params: { patch :update_item, params: {
tournament_id: tournament.to_param, tournament_id: list.tournament.to_param,
key: list.key, key: list.key,
team_id: team.to_param, team_id: team.to_param,
completed: true completed: true
@ -70,5 +163,39 @@ RSpec.describe TeamActionListsController, type: :controller do
expect(response).to have_http_status(:forbidden) expect(response).to have_http_status(:forbidden)
end end
end end
context 'when list key is missing' do
before do
apply_authentication_headers_for list.tournament.owner
end
it 'returns not found' do
patch :update_item, params: {
tournament_id: list.tournament.to_param,
key: 'unknown_key',
team_id: team.to_param,
completed: true
}
expect(response).to have_http_status(:not_found)
end
end
context 'when team is missing from list context' do
before do
apply_authentication_headers_for list.tournament.owner
end
it 'returns not found' do
patch :update_item, params: {
tournament_id: list.tournament.to_param,
key: list.key,
team_id: 999_999_999,
completed: true
}
expect(response).to have_http_status(:not_found)
end
end
end end
end end

View File

@ -214,7 +214,7 @@ RSpec.describe TournamentsController, type: :controller do
body = deserialize_response response body = deserialize_response response
expect(body[:team_action_lists].size).to eq(1) expect(body[:team_action_lists].size).to eq(1)
expect(body[:team_action_lists].first[:key]).to eq('group_stage_survivor_playoff_tokens') expect(body[:team_action_lists].first[:key]).to eq('playoff_token_collection')
expect(body[:team_action_lists].first[:team_action_items].first[:completed]).to eq(true) expect(body[:team_action_lists].first[:team_action_items].first[:completed]).to eq(true)
end end
end end

View File

@ -0,0 +1,101 @@
# frozen_string_literal: true
require 'spec_helper'
require 'securerandom'
require_relative '../../../e2e/lib/api_client'
require_relative '../../../e2e/lib/action_cable_client'
RSpec.describe 'Tournament beamer websocket HTTP E2E' do
required_env_vars = %w[
TURNIERE_E2E_BASE_URL
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') }
let(:owner_email) { ENV.fetch('TURNIERE_E2E_EMAIL') }
let(:owner_password) { ENV.fetch('TURNIERE_E2E_PASSWORD') }
let(:client) { login_client }
it 'broadcasts beamer snapshots and updates over websocket' do
tournament = create_tournament(name_prefix: 'Ws Beamer')
create_beamer = client.post("/tournaments/#{tournament.fetch(:id)}/beamers", body: {
name: 'projector-a',
display_state: 'matches_all'
})
expect(create_beamer[:status]).to eq(201)
beamer = create_beamer.fetch(:json)
cable_client = TurniereE2E::ActionCableClient.new(base_url:)
cable_client.subscribe_beamer!(tournament_id: tournament.fetch(:id), beamer_name: beamer.fetch(:name))
initial_payload = cable_client.wait_for_beamer_payload!
expect(initial_payload.fetch('type')).to eq('beamer.snapshot')
expect(initial_payload.dig('beamer', 'id')).to eq(beamer.fetch(:id))
expect(initial_payload.dig('beamer', 'display_state')).to eq('matches_all')
update = client.patch("/tournaments/#{tournament.fetch(:id)}/beamers/#{beamer.fetch(:id)}", body: {
display_state: 'during_game',
qr_code_url: 'https://example.com/qr',
logo_url: '/static/logo.png'
})
expect(update[:status]).to eq(200)
updated_payload = wait_for_beamer_payload!(
cable_client,
event_type: 'beamer.updated',
display_state: 'during_game'
)
expect(updated_payload.dig('beamer', 'qr_code_url')).to eq('https://example.com/qr')
expect(updated_payload.dig('beamer', 'logo_url')).to eq('/static/logo.png')
delete = client.delete("/tournaments/#{tournament.fetch(:id)}/beamers/#{beamer.fetch(:id)}")
expect(delete[:status]).to eq(204)
deleted_payload = wait_for_beamer_payload!(
cable_client,
event_type: 'beamer.deleted',
display_state: 'during_game'
)
expect(deleted_payload.dig('beamer', 'name')).to eq('projector-a')
ensure
cable_client&.close
end
def login_client
api_client = TurniereE2E::ApiClient.new(base_url:)
response = api_client.login!(email: owner_email, password: owner_password)
expect(response[:status]).to eq(200)
api_client
end
def create_tournament(name_prefix:)
response = client.post('/tournaments', body: {
name: "#{name_prefix} #{SecureRandom.hex(3)}",
description: 'Websocket beamer tournament',
public: true,
teams: 4.times.map do |index|
{ name: "#{name_prefix.tr(' ', '')}-S#{index + 1}" }
end
})
expect(response[:status]).to eq(201)
response.fetch(:json)
end
def wait_for_beamer_payload!(cable_client, event_type:, display_state:, timeout: 20)
deadline = Time.now + timeout
loop do
payload = cable_client.wait_for_beamer_payload!(timeout: [deadline - Time.now, 0.1].max)
return payload if payload.fetch('type') == event_type &&
payload.dig('beamer', 'display_state') == display_state
raise "beamer websocket payload not received within #{timeout}s" if Time.now >= deadline
end
end
end

View File

@ -0,0 +1,131 @@
# frozen_string_literal: true
require 'spec_helper'
require 'securerandom'
require_relative '../../../e2e/lib/api_client'
require_relative '../../../e2e/lib/action_cable_client'
DEFAULT_BEAMER_NAME = 'default'
RSpec.describe 'Tournament follower sync beamer websocket HTTP E2E' do
required_env_vars = %w[
TURNIERE_E2E_EMAIL
TURNIERE_E2E_PASSWORD
TURNIERE_SOURCE_E2E_BASE_URL
TURNIERE_FOLLOWER_E2E_BASE_URL
].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(:source_base_url) { ENV.fetch('TURNIERE_SOURCE_E2E_BASE_URL') }
let(:follower_base_url) { ENV.fetch('TURNIERE_FOLLOWER_E2E_BASE_URL') }
let(:owner_email) { ENV.fetch('TURNIERE_E2E_EMAIL') }
let(:owner_password) { ENV.fetch('TURNIERE_E2E_PASSWORD') }
let(:sync_token) { "sync-#{SecureRandom.hex(8)}" }
let(:source_client) { login_client(base_url: source_base_url) }
it 'broadcasts follower beamer websocket updates after source sync import' do
source = create_tournament(client: source_client, base_url: source_base_url, name_prefix: 'Ws Beamer Source')
follower = create_follower_tournament(name_prefix: 'Ws Beamer Follower')
configure_sync = source_client.patch("/tournaments/#{source.fetch(:id)}", body: {
sync_target_url: "#{follower_base_url}/tournaments/#{follower.fetch(:id)}/sync_state",
sync_auth_token: sync_token
})
expect(configure_sync[:status]).to eq(200)
follower_default_beamer = wait_for_default_beamer!(tournament_id: follower.fetch(:id))
cable_client = TurniereE2E::ActionCableClient.new(base_url: follower_base_url)
cable_client.subscribe_beamer!(tournament_id: follower.fetch(:id), beamer_name: follower_default_beamer.fetch(:name))
cable_client.wait_for_beamer_payload!
source_default_beamer = fetch_beamer(base_url: source_base_url, tournament_id: source.fetch(:id), beamer_name: DEFAULT_BEAMER_NAME)
update = source_client.patch("/tournaments/#{source.fetch(:id)}/beamers/#{source_default_beamer.fetch(:id)}", body: {
display_state: 'groups'
})
expect(update[:status]).to eq(200)
follower_payload = wait_for_beamer_payload!(
cable_client,
event_type: 'beamer.updated',
display_state: 'groups'
)
expect(follower_payload.dig('beamer', 'name')).to eq(DEFAULT_BEAMER_NAME)
ensure
cable_client&.close
end
def login_client(base_url:)
client = TurniereE2E::ApiClient.new(base_url:)
response = client.login!(email: owner_email, password: owner_password)
expect(response[:status]).to eq(200)
client
end
def create_tournament(client:, base_url:, name_prefix:)
response = client.post('/tournaments', body: {
name: "#{name_prefix} #{SecureRandom.hex(3)}",
description: 'Follower beamer websocket source tournament',
public: true,
teams: 4.times.map do |index|
{ name: "#{name_prefix.tr(' ', '')}-S#{index + 1}" }
end
})
expect(response[:status]).to eq(201)
fetch_tournament(base_url:, tournament_id: response.dig(:json, :id))
end
def create_follower_tournament(name_prefix:)
client = login_client(base_url: follower_base_url)
response = client.post('/tournaments', body: {
name: "#{name_prefix} #{SecureRandom.hex(3)}",
description: 'Follower beamer websocket target tournament',
public: true,
read_only_mode: true,
sync_auth_token: sync_token
})
expect(response[:status]).to eq(201)
fetch_tournament(base_url: follower_base_url, tournament_id: response.dig(:json, :id))
end
def fetch_tournament(base_url:, tournament_id:)
client = TurniereE2E::ApiClient.new(base_url:)
response = client.get("/tournaments/#{tournament_id}")
expect(response[:status]).to eq(200)
response.fetch(:json)
end
def fetch_beamer(base_url:, tournament_id:, beamer_name:)
client = TurniereE2E::ApiClient.new(base_url:)
response = client.get("/tournaments/#{tournament_id}/beamers/by_name", params: { name: beamer_name })
expect(response[:status]).to eq(200)
response.fetch(:json)
end
def wait_for_default_beamer!(tournament_id:, timeout: 20)
deadline = Time.now + timeout
loop do
beamer = fetch_beamer(base_url: follower_base_url, tournament_id:, beamer_name: DEFAULT_BEAMER_NAME)
return beamer if beamer.fetch(:name) == DEFAULT_BEAMER_NAME
raise "follower default beamer not present within #{timeout}s" if Time.now >= deadline
sleep 0.5
end
end
def wait_for_beamer_payload!(cable_client, event_type:, display_state:, timeout: 20)
deadline = Time.now + timeout
loop do
payload = cable_client.wait_for_beamer_payload!(timeout: [deadline - Time.now, 0.1].max)
return payload if payload.fetch('type') == event_type &&
payload.dig('beamer', 'display_state') == display_state
raise "follower beamer websocket payload not received within #{timeout}s" if Time.now >= deadline
end
end
end

View File

@ -141,11 +141,29 @@ RSpec.describe 'Tournament follower sync HTTP E2E' do
expect(tournament_signature(follower)).to eq(tournament_signature(source)) expect(tournament_signature(follower)).to eq(tournament_signature(source))
expect(follower.fetch(:stages).map { |stage| stage.fetch(:level) }).to include(-1, 0, 1) expect(follower.fetch(:stages).map { |stage| stage.fetch(:level) }).to include(-1, 0, 1)
expect(synced_team_id_pairs(source: source, follower: follower)).not_to be_empty expect(synced_team_id_pairs(source: source, follower: follower)).not_to be_empty
expect(source.fetch(:team_action_lists)).to eq([])
expect(follower.fetch(:team_action_lists)).to eq([])
log_phase!('create team action list on source and verify follower mirrors it')
created_list = create_team_action_list!(source.fetch(:id))
expect(created_list.fetch(:key)).to eq(playoff_token_list_key)
source = fetch_tournament(client: source_anonymous_client, tournament_id: source.fetch(:id))
follower = wait_for_tournament_sync!(source_tournament_id: source.fetch(:id), follower_tournament_id: follower.fetch(:id))
expect(playoff_token_list(source)).not_to be_nil expect(playoff_token_list(source)).not_to be_nil
expect(playoff_token_list(follower)).not_to be_nil expect(playoff_token_list(follower)).not_to be_nil
expect(playoff_token_list(follower).fetch(:team_action_items).map { |item| item.dig(:team, :name) }) expect(playoff_token_list(follower).fetch(:team_action_items).map { |item| item.dig(:team, :name) })
.to match_array(playoff_token_list(source).fetch(:team_action_items).map { |item| item.dig(:team, :name) }) .to match_array(playoff_token_list(source).fetch(:team_action_items).map { |item| item.dig(:team, :name) })
locked_list_create = follower_owner_client.post("/tournaments/#{follower.fetch(:id)}/team_action_lists", body: {
name: 'Follower created list',
key: 'follower_created_list',
action_name: 'Should fail',
source: 'all_tournament_teams'
})
expect(locked_list_create[:status]).to eq(423)
expect(locked_list_create.dig(:json, :error)).to eq('Tournament is in read only follower mode')
follower_group = follower.fetch(:stages).find { |stage| stage.fetch(:level) == -1 }.fetch(:groups).first follower_group = follower.fetch(:stages).find { |stage| stage.fetch(:level) == -1 }.fetch(:groups).first
follower_team = follower_group.fetch(:matches).first.fetch(:match_scores).first.fetch(:team) follower_team = follower_group.fetch(:matches).first.fetch(:match_scores).first.fetch(:team)
locked_team_update = follower_owner_client.patch("/teams/#{follower_team.fetch(:id)}", body: { name: 'Nope' }) locked_team_update = follower_owner_client.patch("/teams/#{follower_team.fetch(:id)}", body: { name: 'Nope' })
@ -155,7 +173,7 @@ RSpec.describe 'Tournament follower sync HTTP E2E' do
log_phase!('mark playoff token collected on source through business key route and verify follower mirrors it') log_phase!('mark playoff token collected on source through business key route and verify follower mirrors it')
source_token_item = playoff_token_list(source).fetch(:team_action_items).first source_token_item = playoff_token_list(source).fetch(:team_action_items).first
source_token_update = source_owner_client.patch( source_token_update = source_owner_client.patch(
"/tournaments/#{source.fetch(:id)}/team_action_lists/group_stage_survivor_playoff_tokens/teams/#{source_token_item.dig(:team, :id)}", "/tournaments/#{source.fetch(:id)}/team_action_lists/#{playoff_token_list_key}/teams/#{source_token_item.dig(:team, :id)}",
body: { completed: true } body: { completed: true }
) )
expect(source_token_update[:status]).to eq(200) expect(source_token_update[:status]).to eq(200)
@ -167,7 +185,7 @@ RSpec.describe 'Tournament follower sync HTTP E2E' do
follower_token_item = find_team_action_item(follower, team_name: source_token_item.dig(:team, :name)) follower_token_item = find_team_action_item(follower, team_name: source_token_item.dig(:team, :name))
locked_token_update = follower_owner_client.patch( locked_token_update = follower_owner_client.patch(
"/tournaments/#{follower.fetch(:id)}/team_action_lists/group_stage_survivor_playoff_tokens/teams/#{follower_token_item.dig(:team, :id)}", "/tournaments/#{follower.fetch(:id)}/team_action_lists/#{playoff_token_list_key}/teams/#{follower_token_item.dig(:team, :id)}",
body: { completed: false } body: { completed: false }
) )
expect(locked_token_update[:status]).to eq(423) expect(locked_token_update[:status]).to eq(423)
@ -265,6 +283,17 @@ RSpec.describe 'Tournament follower sync HTTP E2E' do
response.fetch(:json) response.fetch(:json)
end end
def create_team_action_list!(tournament_id)
response = source_owner_client.post("/tournaments/#{tournament_id}/team_action_lists", body: {
name: 'Playoff token collection',
key: playoff_token_list_key,
action_name: 'Collect playoff token',
source: 'group_stage_survivors'
})
expect(response[:status]).to eq(201)
response.fetch(:json)
end
def seed_unrelated_tournaments!(client:, prefix:, range:) def seed_unrelated_tournaments!(client:, prefix:, range:)
amount = rand(range) amount = rand(range)
Array.new(amount) do |index| Array.new(amount) do |index|
@ -552,10 +581,14 @@ RSpec.describe 'Tournament follower sync HTTP E2E' do
def playoff_token_list(tournament) def playoff_token_list(tournament)
tournament.fetch(:team_action_lists, []).find do |list| tournament.fetch(:team_action_lists, []).find do |list|
list.fetch(:key) == 'group_stage_survivor_playoff_tokens' list.fetch(:key) == playoff_token_list_key
end end
end end
def playoff_token_list_key
'playoff_token_collection'
end
def find_team_action_item(tournament, team_name:) def find_team_action_item(tournament, team_name:)
playoff_token_list(tournament).fetch(:team_action_items).find do |item| playoff_token_list(tournament).fetch(:team_action_items).find do |item|
item.dig(:team, :name) == team_name item.dig(:team, :name) == team_name

View File

@ -37,6 +37,7 @@ RSpec.describe 'Tournament follower sync websocket HTTP E2E' do
expect(configure_sync[:status]).to eq(200) expect(configure_sync[:status]).to eq(200)
finish_group_stage_and_create_playoffs!(source_id: source.fetch(:id)) finish_group_stage_and_create_playoffs!(source_id: source.fetch(:id))
create_team_action_list!(source.fetch(:id))
follower = wait_for_list_presence!(follower.fetch(:id)) follower = wait_for_list_presence!(follower.fetch(:id))
cable_client = TurniereE2E::ActionCableClient.new(base_url: follower_base_url) cable_client = TurniereE2E::ActionCableClient.new(base_url: follower_base_url)
@ -106,6 +107,17 @@ RSpec.describe 'Tournament follower sync websocket HTTP E2E' do
response.fetch(:json) response.fetch(:json)
end end
def create_team_action_list!(tournament_id)
response = source_client.post("/tournaments/#{tournament_id}/team_action_lists", body: {
name: 'Playoff token collection',
key: playoff_token_list_key,
action_name: 'Collect playoff token',
source: 'group_stage_survivors'
})
expect(response[:status]).to eq(201)
response.fetch(:json)
end
def finish_group_stage_and_create_playoffs!(source_id:) def finish_group_stage_and_create_playoffs!(source_id:)
tournament = fetch_tournament(base_url: source_base_url, tournament_id: source_id) tournament = fetch_tournament(base_url: source_base_url, tournament_id: source_id)
group_stage = tournament.fetch(:stages).find { |stage| stage.fetch(:level) == -1 } group_stage = tournament.fetch(:stages).find { |stage| stage.fetch(:level) == -1 }
@ -161,13 +173,17 @@ RSpec.describe 'Tournament follower sync websocket HTTP E2E' do
def find_playoff_token_list!(tournament) def find_playoff_token_list!(tournament)
tournament.fetch(:team_action_lists).find do |list| tournament.fetch(:team_action_lists).find do |list|
list.fetch(:key) == 'group_stage_survivor_playoff_tokens' list.fetch(:key) == playoff_token_list_key
end || raise('playoff token list missing') end || raise('playoff token list missing')
end end
def team_action_item_completed?(payload, team_name:) def team_action_item_completed?(payload, team_name:)
list = payload.fetch('team_action_lists').find { |candidate| candidate.fetch('key') == 'group_stage_survivor_playoff_tokens' } list = payload.fetch('team_action_lists').find { |candidate| candidate.fetch('key') == playoff_token_list_key }
item = list.fetch('team_action_items').find { |candidate| candidate.dig('team', 'name') == team_name } item = list.fetch('team_action_items').find { |candidate| candidate.dig('team', 'name') == team_name }
item.fetch('completed') item.fetch('completed')
end end
def playoff_token_list_key
'playoff_token_collection'
end
end end

View File

@ -63,6 +63,7 @@ RSpec.describe 'Tournament sync test HTTP E2E' do
) )
finish_group_stage_and_create_playoffs!(leader.fetch(:id)) finish_group_stage_and_create_playoffs!(leader.fetch(:id))
create_team_action_list!(leader.fetch(:id))
leader_after_playoffs = fetch_tournament(leader.fetch(:id)) leader_after_playoffs = fetch_tournament(leader.fetch(:id))
token_list = find_playoff_token_list!(leader_after_playoffs) token_list = find_playoff_token_list!(leader_after_playoffs)
@ -73,7 +74,7 @@ RSpec.describe 'Tournament sync test HTTP E2E' do
expect(update_by_item_id[:status]).to eq(200) expect(update_by_item_id[:status]).to eq(200)
update_by_business_key = client.patch( update_by_business_key = client.patch(
"/tournaments/#{leader.fetch(:id)}/team_action_lists/group_stage_survivor_playoff_tokens/teams/#{second_item.dig(:team, :id)}", "/tournaments/#{leader.fetch(:id)}/team_action_lists/#{playoff_token_list_key}/teams/#{second_item.dig(:team, :id)}",
body: { completed: true } body: { completed: true }
) )
expect(update_by_business_key[:status]).to eq(200) expect(update_by_business_key[:status]).to eq(200)
@ -90,6 +91,32 @@ RSpec.describe 'Tournament sync test HTTP E2E' do
expect(playoff_token_item_completed?(follower_after_update, team_name: second_item.dig(:team, :name))).to eq(true) expect(playoff_token_item_completed?(follower_after_update, team_name: second_item.dig(:team, :name))).to eq(true)
end end
it 'returns 404 for missing team action resources on both update routes' do
leader = create_group_stage_tournament(name_prefix: 'Missing Action Resource Leader')
finish_group_stage_and_create_playoffs!(leader.fetch(:id))
create_team_action_list!(leader.fetch(:id))
leader_after_playoffs = fetch_tournament(leader.fetch(:id))
token_list = find_playoff_token_list!(leader_after_playoffs)
first_item = token_list.fetch(:team_action_items).first
missing_item_update = client.patch('/team_action_items/999999999', body: { completed: true })
expect(missing_item_update[:status]).to eq(404)
missing_list_update = client.patch(
"/tournaments/#{leader.fetch(:id)}/team_action_lists/unknown_key/teams/#{first_item.dig(:team, :id)}",
body: { completed: true }
)
expect(missing_list_update[:status]).to eq(404)
missing_team_update = client.patch(
"/tournaments/#{leader.fetch(:id)}/team_action_lists/#{playoff_token_list_key}/teams/999999999",
body: { completed: true }
)
expect(missing_team_update[:status]).to eq(404)
end
def login_client def login_client
api_client = TurniereE2E::ApiClient.new(base_url: base_url) api_client = TurniereE2E::ApiClient.new(base_url: base_url)
response = api_client.login!(email: owner_email, password: owner_password) response = api_client.login!(email: owner_email, password: owner_password)
@ -154,6 +181,17 @@ RSpec.describe 'Tournament sync test HTTP E2E' do
expect(finish_stage[:status]).to eq(200) expect(finish_stage[:status]).to eq(200)
end end
def create_team_action_list!(tournament_id)
response = client.post("/tournaments/#{tournament_id}/team_action_lists", body: {
name: 'Playoff token collection',
key: playoff_token_list_key,
action_name: 'Collect playoff token',
source: 'group_stage_survivors'
})
expect(response[:status]).to eq(201)
response.fetch(:json)
end
def play_match!(match_id, first_score:, second_score:) def play_match!(match_id, first_score:, second_score:)
match_response = anonymous_client.get("/matches/#{match_id}") match_response = anonymous_client.get("/matches/#{match_id}")
expect(match_response[:status]).to eq(200) expect(match_response[:status]).to eq(200)
@ -173,10 +211,14 @@ RSpec.describe 'Tournament sync test HTTP E2E' do
def find_playoff_token_list!(tournament) def find_playoff_token_list!(tournament)
tournament.fetch(:team_action_lists).find do |list| tournament.fetch(:team_action_lists).find do |list|
list.fetch(:key) == 'group_stage_survivor_playoff_tokens' list.fetch(:key) == playoff_token_list_key
end || raise('playoff token list missing') end || raise('playoff token list missing')
end end
def playoff_token_list_key
'playoff_token_collection'
end
def playoff_token_item_completed?(tournament, team_name:) def playoff_token_item_completed?(tournament, team_name:)
list = find_playoff_token_list!(tournament) list = find_playoff_token_list!(tournament)
item = list.fetch(:team_action_items).find { |candidate| candidate.dig(:team, :name) == team_name } item = list.fetch(:team_action_items).find { |candidate| candidate.dig(:team, :name) == team_name }

View File

@ -26,6 +26,7 @@ RSpec.describe 'Tournament team action lists websocket HTTP E2E' do
it 'broadcasts initial and updated team action list state over websocket' do it 'broadcasts initial and updated team action list state over websocket' do
tournament = create_group_stage_tournament(name_prefix: 'Ws Leader') tournament = create_group_stage_tournament(name_prefix: 'Ws Leader')
finish_group_stage_and_create_playoffs!(tournament.fetch(:id)) finish_group_stage_and_create_playoffs!(tournament.fetch(:id))
create_team_action_list!(tournament.fetch(:id))
tournament = fetch_tournament(tournament.fetch(:id)) tournament = fetch_tournament(tournament.fetch(:id))
token_list = find_playoff_token_list!(tournament) token_list = find_playoff_token_list!(tournament)
@ -33,7 +34,7 @@ RSpec.describe 'Tournament team action lists websocket HTTP E2E' do
cable_client.subscribe_team_action_lists!(tournament_id: tournament.fetch(:id)) cable_client.subscribe_team_action_lists!(tournament_id: tournament.fetch(:id))
initial_payload = cable_client.wait_for_payload! initial_payload = cable_client.wait_for_payload!
expect(initial_payload.fetch('team_action_lists').first.fetch('key')).to eq('group_stage_survivor_playoff_tokens') expect(initial_payload.fetch('team_action_lists').first.fetch('key')).to eq(playoff_token_list_key)
first_item = token_list.fetch(:team_action_items).first first_item = token_list.fetch(:team_action_items).first
second_item = token_list.fetch(:team_action_items)[1] second_item = token_list.fetch(:team_action_items)[1]
@ -49,7 +50,7 @@ RSpec.describe 'Tournament team action lists websocket HTTP E2E' do
expect(team_action_item_completed?(first_update, team_name: first_item.dig(:team, :name))).to eq(true) expect(team_action_item_completed?(first_update, team_name: first_item.dig(:team, :name))).to eq(true)
update_by_business_key = client.patch( update_by_business_key = client.patch(
"/tournaments/#{tournament.fetch(:id)}/team_action_lists/group_stage_survivor_playoff_tokens/teams/#{second_item.dig(:team, :id)}", "/tournaments/#{tournament.fetch(:id)}/team_action_lists/#{playoff_token_list_key}/teams/#{second_item.dig(:team, :id)}",
body: { completed: true } body: { completed: true }
) )
expect(update_by_business_key[:status]).to eq(200) expect(update_by_business_key[:status]).to eq(200)
@ -113,6 +114,17 @@ RSpec.describe 'Tournament team action lists websocket HTTP E2E' do
expect(finish_stage[:status]).to eq(200) expect(finish_stage[:status]).to eq(200)
end end
def create_team_action_list!(tournament_id)
response = client.post("/tournaments/#{tournament_id}/team_action_lists", body: {
name: 'Playoff token collection',
key: playoff_token_list_key,
action_name: 'Collect playoff token',
source: 'group_stage_survivors'
})
expect(response[:status]).to eq(201)
response.fetch(:json)
end
def play_match!(match_id, first_score:, second_score:) def play_match!(match_id, first_score:, second_score:)
match_response = anonymous_client.get("/matches/#{match_id}") match_response = anonymous_client.get("/matches/#{match_id}")
expect(match_response[:status]).to eq(200) expect(match_response[:status]).to eq(200)
@ -142,13 +154,17 @@ RSpec.describe 'Tournament team action lists websocket HTTP E2E' do
def find_playoff_token_list!(tournament) def find_playoff_token_list!(tournament)
tournament.fetch(:team_action_lists).find do |list| tournament.fetch(:team_action_lists).find do |list|
list.fetch(:key) == 'group_stage_survivor_playoff_tokens' list.fetch(:key) == playoff_token_list_key
end || raise('playoff token list missing') end || raise('playoff token list missing')
end end
def team_action_item_completed?(payload, team_name:) def team_action_item_completed?(payload, team_name:)
list = payload.fetch('team_action_lists').find { |candidate| candidate.fetch('key') == 'group_stage_survivor_playoff_tokens' } list = payload.fetch('team_action_lists').find { |candidate| candidate.fetch('key') == playoff_token_list_key }
item = list.fetch('team_action_items').find { |candidate| candidate.dig('team', 'name') == team_name } item = list.fetch('team_action_items').find { |candidate| candidate.dig('team', 'name') == team_name }
item.fetch('completed') item.fetch('completed')
end end
def playoff_token_list_key
'playoff_token_collection'
end
end end

View File

@ -4,8 +4,8 @@ FactoryBot.define do
factory :team_action_list do factory :team_action_list do
tournament tournament
name { 'Playoff token collection' } name { 'Playoff token collection' }
key { 'group_stage_survivor_playoff_tokens' } key { 'playoff_token_collection' }
action_name { 'Collect playoff token' } action_name { 'Collect playoff token' }
source { 'group_stage_survivors' } source { 'all_tournament_teams' }
end end
end end

View File

@ -42,4 +42,54 @@ RSpec.describe Beamer, type: :model do
expect(beamer.owner).to eq(beamer.tournament.owner) expect(beamer.owner).to eq(beamer.tournament.owner)
end end
end end
describe 'live broadcasts' do
it 'broadcasts create payload to current beamer stream' do
tournament = create(:tournament)
payload = nil
expect(BeamerBroadcast).to receive(:call) do |args|
payload = args.fetch(:payload)
expect(args.fetch(:tournament_id)).to eq(tournament.id)
expect(args.fetch(:beamer_names)).to eq(['projector'])
end
tournament.beamers.create!(name: 'projector', display_state: 'groups')
expect(payload[:type]).to eq('beamer.created')
expect(payload.dig(:beamer, :display_state)).to eq('groups')
end
it 'broadcasts update payload to previous and current beamer names when renamed' do
beamer = create(:beamer, name: 'old-name')
payload = nil
expect(BeamerBroadcast).to receive(:call) do |args|
payload = args.fetch(:payload)
expect(args.fetch(:beamer_names)).to contain_exactly('old-name', 'new-name')
end
beamer.update!(name: 'new-name', display_state: 'during_game')
expect(payload[:type]).to eq('beamer.updated')
expect(payload.dig(:beamer, :name)).to eq('new-name')
expect(payload.dig(:beamer, :display_state)).to eq('during_game')
end
it 'broadcasts delete payload with last persisted snapshot' do
beamer = create(:beamer, name: 'projector', display_state: 'between_games')
payload = nil
expect(BeamerBroadcast).to receive(:call) do |args|
payload = args.fetch(:payload)
expect(args.fetch(:beamer_names)).to eq(['projector'])
end
beamer.destroy!
expect(payload[:type]).to eq('beamer.deleted')
expect(payload.dig(:beamer, :name)).to eq('projector')
expect(payload.dig(:beamer, :display_state)).to eq('between_games')
end
end
end end

View File

@ -4,12 +4,17 @@ require 'rails_helper'
RSpec.describe TeamActionListsController, type: :routing do RSpec.describe TeamActionListsController, type: :routing do
describe 'routing' do describe 'routing' do
it 'routes to #create via POST' do
expect(post: '/tournaments/1/team_action_lists')
.to route_to('team_action_lists#create', tournament_id: '1')
end
it 'routes to #update_item via PATCH' do it 'routes to #update_item via PATCH' do
expect(patch: '/tournaments/1/team_action_lists/group_stage_survivor_playoff_tokens/teams/2') expect(patch: '/tournaments/1/team_action_lists/playoff_token_collection/teams/2')
.to route_to( .to route_to(
'team_action_lists#update_item', 'team_action_lists#update_item',
tournament_id: '1', tournament_id: '1',
key: 'group_stage_survivor_playoff_tokens', key: 'playoff_token_collection',
team_id: '2' team_id: '2'
) )
end end

View File

@ -0,0 +1,68 @@
# frozen_string_literal: true
require 'rails_helper'
RSpec.describe TeamActionListService do
describe '.create!' do
let(:tournament) { create(:tournament, teams_count: 4) }
it 'creates a list with items for all tournament teams' do
list = described_class.create!(
tournament:,
name: 'Team check-in',
key: 'team_check_in',
action_name: 'Check in',
source: 'all_tournament_teams'
)
expect(list.team_action_items.includes(:team).map(&:team)).to match_array(tournament.teams)
end
it 'creates a list for group stage survivors when available' do
group_stage_tournament = create(:tournament, teams_count: 0, playoff_teams_amount: 2, instant_finalists_amount: 2)
group_stage = create(:group_stage, tournament: group_stage_tournament, group_count: 0)
group = create(:group, stage: group_stage, match_count: 0, number: 1)
team_a = create(:team, tournament: group_stage_tournament, name: 'A')
team_b = create(:team, tournament: group_stage_tournament, name: 'B')
create(:group_score, group:, team: team_a, group_points: 6, scored_points: 6, received_points: 0, position: 1)
create(:group_score, group:, team: team_b, group_points: 3, scored_points: 3, received_points: 3, position: 2)
group_stage.update!(state: 'finished')
list = described_class.create!(
tournament: group_stage_tournament,
name: 'Playoff token collection',
key: 'playoff_token_collection',
action_name: 'Collect playoff token',
source: 'group_stage_survivors'
)
expect(list.team_action_items.includes(:team).map(&:team)).to match_array([team_a, team_b])
end
it 'raises for unsupported source' do
expect do
described_class.create!(
tournament:,
name: 'Broken',
key: 'broken',
action_name: 'Break',
source: 'unknown_source'
)
end.to raise_error(TeamActionListService::InvalidSourceError, 'Unsupported team action list source: unknown_source')
end
it 'raises when source resolves to no teams' do
empty_tournament = create(:tournament, teams_count: 0)
expect do
described_class.create!(
tournament: empty_tournament,
name: 'Empty',
key: 'empty',
action_name: 'Nothing',
source: 'all_tournament_teams'
)
end.to raise_error(TeamActionListService::EmptySourceError, 'Source all_tournament_teams does not currently contain any teams')
end
end
end

View File

@ -31,6 +31,9 @@ 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"
TEST_TAG = "registry.gitlab.com/turniere/turniere-backend/test/commits:local" TEST_TAG = "registry.gitlab.com/turniere/turniere-backend/test/commits:local"
DOCKERFILE_PATH = "Dockerfile"
PRODUCTION_DOCKER_TARGET = "production"
TEST_DOCKER_TARGET = "test"
BLACKBOX_COMPOSE_FILE = "docker-compose.blackbox.yml" BLACKBOX_COMPOSE_FILE = "docker-compose.blackbox.yml"
BLACKBOX_PROJECT = "turniere-blackbox" BLACKBOX_PROJECT = "turniere-blackbox"
BLACKBOX_FOLLOW_COMPOSE_FILE = "docker-compose.blackbox-follow.yml" BLACKBOX_FOLLOW_COMPOSE_FILE = "docker-compose.blackbox-follow.yml"
@ -46,9 +49,10 @@ BLACKBOX_MAILGUN_API_KEY = "blackbox-test-api-key"
BLACKBOX_MAILGUN_DOMAIN = "blackbox.example.com" 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()
ROOT = Path(__file__).resolve().parent ROOT = Path(__file__).resolve().parent
DB_DIR = ROOT / "db" DB_DIR = ROOT / "db"
GEMFILE_LOCK_PATH = ROOT / "Gemfile.lock"
BUNDLER_VERSION = GEMFILE_LOCK_PATH.read_text(encoding="utf-8").split("BUNDLED WITH", 1)[1].strip().splitlines()[-1].strip()
def _env(**overrides): def _env(**overrides):
@ -228,6 +232,31 @@ def _format_command(command):
return " ".join(shlex.quote(part) for part in command) return " ".join(shlex.quote(part) for part in command)
def _docker_build_command(tag, target, platforms=None, push=False):
if platforms and "," in str(platforms) and not push:
raise ValueError("multi-platform buildx builds require push=True")
if platforms or push:
command = [
"docker",
"buildx",
"build",
"--target",
target,
"-t",
tag,
"-f",
DOCKERFILE_PATH,
]
if platforms:
command.extend(["--platform", platforms])
command.append("--push" if push else "--load")
command.append(".")
return _format_command(command)
return f"docker build --target {target} -t {tag} -f {DOCKERFILE_PATH} ."
def _rspec_command(*paths): def _rspec_command(*paths):
command = ["bundle", "exec", "rspec", *paths] command = ["bundle", "exec", "rspec", *paths]
if os.environ.get("CI"): if os.environ.get("CI"):
@ -815,15 +844,15 @@ def scenario_render_profile(
@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, platforms=None, push=False):
"""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(_docker_build_command(tag, PRODUCTION_DOCKER_TARGET, platforms=platforms, push=push), 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, platforms=None, push=False):
"""Build the test Docker image.""" """Build the test Docker image."""
c.run(f"docker build --build-arg base_commit=local -t {tag} -f docker/test/Dockerfile .", pty=True) c.run(_docker_build_command(tag, TEST_DOCKER_TARGET, platforms=platforms, push=push), pty=True)
@task(name="docker-build-all") @task(name="docker-build-all")
@ -1697,6 +1726,7 @@ def blackbox_follow_service(
"spec/e2e/http/tournament_follow_sync_spec.rb", "spec/e2e/http/tournament_follow_sync_spec.rb",
"spec/e2e/http/tournament_follow_sync_websocket_spec.rb", "spec/e2e/http/tournament_follow_sync_websocket_spec.rb",
"spec/e2e/http/tournament_timer_follow_sync_websocket_spec.rb", "spec/e2e/http/tournament_timer_follow_sync_websocket_spec.rb",
"spec/e2e/http/tournament_follow_sync_beamers_websocket_spec.rb",
], ],
extra_env={ extra_env={
"TURNIERE_SOURCE_E2E_BASE_URL": source_base_url, "TURNIERE_SOURCE_E2E_BASE_URL": source_base_url,