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:
commit
b0bbbb8ce5
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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"]
|
||||||
|
|
@ -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)
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
@ -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
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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]
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
@ -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
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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
|
|
||||||
|
|
@ -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
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
@ -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
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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 }
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
40
tasks.py
40
tasks.py
|
|
@ -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,
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue