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:
|
||||
DOCKER_IMAGE_ENVS: "production test"
|
||||
DOCKERFILE_PATH: "Dockerfile"
|
||||
DOCKER_BUILD_TARGETS: "production=production test=test"
|
||||
|
||||
include:
|
||||
- project: 'turniere/turniere-infra'
|
||||
|
|
@ -13,10 +15,10 @@ include:
|
|||
|
||||
rails spec:
|
||||
stage: test
|
||||
needs:
|
||||
- job: build_image
|
||||
tags:
|
||||
- docker
|
||||
needs:
|
||||
- job: build_image
|
||||
image: $CI_REGISTRY_IMAGE/test/commits:$CI_COMMIT_SHA
|
||||
parallel:
|
||||
matrix:
|
||||
|
|
@ -43,10 +45,10 @@ rails spec:
|
|||
|
||||
rails e2e spec:
|
||||
stage: e2e
|
||||
needs:
|
||||
- job: build_image
|
||||
tags:
|
||||
- docker
|
||||
needs:
|
||||
- job: build_image
|
||||
image: $CI_REGISTRY_IMAGE/test/commits:$CI_COMMIT_SHA
|
||||
services:
|
||||
- name: postgres:16
|
||||
|
|
@ -96,10 +98,10 @@ rails e2e spec:
|
|||
|
||||
rails follow sync e2e spec:
|
||||
stage: e2e
|
||||
needs:
|
||||
- job: build_image
|
||||
tags:
|
||||
- docker
|
||||
needs:
|
||||
- job: build_image
|
||||
image: $CI_REGISTRY_IMAGE/test/commits:$CI_COMMIT_SHA
|
||||
services:
|
||||
- name: postgres:16
|
||||
|
|
|
|||
|
|
@ -176,6 +176,7 @@ Practical rule:
|
|||
|
||||
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
|
||||
- channel name is `TournamentTeamActionListsChannel`
|
||||
- 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:
|
||||
|
||||
- [doc/leader_follower.md](doc/leader_follower.md)
|
||||
- [doc/beamer_live_updates.md](doc/beamer_live_updates.md)
|
||||
|
||||
## Docker
|
||||
[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
|
||||
end
|
||||
|
||||
return unless AddPlayoffsToTournamentAndSave.call(tournament: @stage.tournament,
|
||||
teams: GroupStageService.get_advancing_teams(@stage)).success?
|
||||
|
||||
TeamActionListService.ensure_group_stage_survivor_playoff_token_list!(@stage.tournament)
|
||||
return
|
||||
return if AddPlayoffsToTournamentAndSave.call(tournament: @stage.tournament,
|
||||
teams: GroupStageService.get_advancing_teams(@stage)).success?
|
||||
|
||||
render json: { error: 'Generating group stage failed' }, status: :unprocessable_entity
|
||||
raise ActiveRecord::Rollback
|
||||
|
|
|
|||
|
|
@ -5,6 +5,7 @@ class TeamActionItemsController < ApplicationController
|
|||
before_action :authenticate_user!, 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]
|
||||
rescue_from ActiveRecord::RecordNotFound, with: :render_not_found_error
|
||||
|
||||
# PATCH /team_action_items/1
|
||||
def update
|
||||
|
|
|
|||
|
|
@ -2,10 +2,28 @@
|
|||
|
||||
class TeamActionListsController < ApplicationController
|
||||
before_action :set_tournament
|
||||
before_action :authenticate_user!, only: %i[update_item]
|
||||
before_action -> { require_owner! @tournament.owner }, only: %i[update_item]
|
||||
before_action -> { require_writable_tournament!(@tournament) }, only: %i[update_item]
|
||||
before_action :authenticate_user!, only: %i[create update_item]
|
||||
before_action -> { require_owner! @tournament.owner }, only: %i[create update_item]
|
||||
before_action -> { require_writable_tournament!(@tournament) }, only: %i[create 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
|
||||
def update_item
|
||||
|
|
@ -33,4 +51,8 @@ class TeamActionListsController < ApplicationController
|
|||
def team_action_item_params
|
||||
params.slice(:completed).permit(:completed)
|
||||
end
|
||||
|
||||
def team_action_list_params
|
||||
params.permit(:name, :key, :action_name, :source)
|
||||
end
|
||||
end
|
||||
|
|
|
|||
|
|
@ -13,4 +13,37 @@ class Beamer < ApplicationRecord
|
|||
validates :display_state, presence: true, inclusion: { in: DISPLAY_STATES }
|
||||
|
||||
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
|
||||
|
|
|
|||
|
|
@ -6,6 +6,7 @@ class TeamActionList < ApplicationRecord
|
|||
|
||||
validates :name, :key, :action_name, :source, presence: true
|
||||
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
|
||||
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
# frozen_string_literal: true
|
||||
|
||||
class BeamerSerializer < ApplicationSerializer
|
||||
attributes :name, :display_state, :qr_code_url, :logo_url
|
||||
attributes :id, :name, :display_state, :qr_code_url, :logo_url
|
||||
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
|
||||
|
||||
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
|
||||
def ensure_group_stage_survivor_playoff_token_list!(tournament)
|
||||
group_stage = tournament.group_stage
|
||||
return nil unless group_stage&.finished?
|
||||
def create!(tournament:, name:, key:, action_name:, source:)
|
||||
teams = teams_for_source!(tournament:, source:)
|
||||
raise EmptySourceError, "Source #{source} does not currently contain any teams" if teams.empty?
|
||||
|
||||
teams = tournament.teams_advancing_from_group_stage
|
||||
return nil if teams.empty?
|
||||
list = nil
|
||||
TeamActionList.transaction do
|
||||
list = tournament.team_action_lists.new(
|
||||
name:,
|
||||
key:,
|
||||
action_name:,
|
||||
source:
|
||||
)
|
||||
list.save!
|
||||
list.sync_teams!(teams)
|
||||
end
|
||||
|
||||
ensure_list!(
|
||||
tournament: tournament,
|
||||
key: GROUP_STAGE_SURVIVOR_PLAYOFF_TOKEN_KEY,
|
||||
name: 'Playoff token collection',
|
||||
action_name: 'Collect playoff token',
|
||||
source: 'group_stage_survivors',
|
||||
teams: teams
|
||||
)
|
||||
TournamentSyncEnqueue.call(tournament)
|
||||
list
|
||||
end
|
||||
|
||||
def ensure_list!(tournament:, key:, name:, action_name:, source:, teams:)
|
||||
list = tournament.team_action_lists.find_or_initialize_by(key: key)
|
||||
list.name = name
|
||||
list.action_name = action_name
|
||||
list.source = source
|
||||
list.save!
|
||||
list.sync_teams!(teams)
|
||||
list
|
||||
def source_keys
|
||||
SUPPORTED_SOURCES
|
||||
end
|
||||
|
||||
def teams_for_source!(tournament:, source:)
|
||||
case source
|
||||
when 'all_tournament_teams'
|
||||
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
|
||||
|
|
|
|||
|
|
@ -21,6 +21,7 @@ Rails.application.routes.draw do
|
|||
resources :teams, only: %i[show update]
|
||||
resources :team_action_items, only: %i[update]
|
||||
resources :tournaments do
|
||||
resources :team_action_lists, only: %i[create]
|
||||
patch 'team_action_lists/:key/teams/:team_id', to: 'team_action_lists#update_item'
|
||||
resources :statistics, 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
|
||||
|
||||
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
|
||||
|
||||
|
|
@ -26,7 +32,7 @@ Example response shape:
|
|||
{
|
||||
"id": 9,
|
||||
"name": "Playoff token collection",
|
||||
"key": "group_stage_survivor_playoff_tokens",
|
||||
"key": "playoff_token_collection",
|
||||
"action_name": "Collect playoff token",
|
||||
"source": "group_stage_survivors",
|
||||
"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
|
||||
|
||||
Only tournament owner may change item status.
|
||||
|
|
@ -79,7 +129,7 @@ Endpoint:
|
|||
|
||||
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:
|
||||
|
||||
|
|
@ -93,7 +143,12 @@ Response body for both write endpoints is the updated `TeamActionItem`.
|
|||
|
||||
## 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:
|
||||
|
||||
|
|
@ -103,12 +158,12 @@ Good:
|
|||
Avoid:
|
||||
|
||||
- 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=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)`
|
||||
|
||||
`completed_at` behavior:
|
||||
|
|
@ -122,10 +177,10 @@ Why this is idempotent:
|
|||
Recommended frontend flow:
|
||||
|
||||
1. Load tournament via `GET /tournaments/:id`
|
||||
2. Find desired list by stable `key`
|
||||
3. Render `team_action_items`
|
||||
4. On user action, send explicit target state
|
||||
5. Update local UI from response or refetch tournament
|
||||
2. Render current `team_action_lists`
|
||||
3. If owner wants a new checklist, send `POST /tournaments/:id/team_action_lists`
|
||||
4. For item changes, send explicit target state for one item only
|
||||
5. Update local UI from response or websocket snapshot
|
||||
|
||||
Recommended frontend identifiers:
|
||||
|
||||
|
|
@ -135,6 +190,7 @@ Recommended frontend identifiers:
|
|||
|
||||
Recommended optimistic UI:
|
||||
|
||||
- create list only once per intended key
|
||||
- set checkbox immediately to requested target state
|
||||
- revert if API returns error
|
||||
- do not compute next state from stale cached data if multiple clients may edit
|
||||
|
|
@ -143,11 +199,13 @@ Recommended optimistic UI:
|
|||
|
||||
Expected statuses:
|
||||
|
||||
- `200` update accepted
|
||||
- `201` list created
|
||||
- `200` item update accepted
|
||||
- `401` unauthenticated
|
||||
- `403` authenticated but not tournament owner
|
||||
- `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
|
||||
- `401` unauthenticated
|
||||
|
||||
## Sync / Follower Behavior
|
||||
|
||||
|
|
@ -155,10 +213,12 @@ Team action lists are included in tournament sync snapshots.
|
|||
|
||||
That means:
|
||||
|
||||
- source tournament owner updates item state
|
||||
- source owner creates list on source tournament
|
||||
- backend enqueues tournament sync
|
||||
- follower tournament receives updated list state
|
||||
- follower users can view but not mutate item state while read-only
|
||||
- follower tournament receives created list and all items
|
||||
- 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
|
||||
|
||||
|
|
@ -168,7 +228,7 @@ This is a websocket flow, not a webhook flow.
|
|||
|
||||
- backend pushes updates to subscribed clients over websocket
|
||||
- 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:
|
||||
|
||||
|
|
@ -190,11 +250,12 @@ Subscription identifier example:
|
|||
Behavior:
|
||||
|
||||
- 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:
|
||||
- direct source updates
|
||||
- list creation
|
||||
- direct item updates
|
||||
- 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 /tournaments/:tournament_id/team_action_lists/:key/teams/:team_id`
|
||||
|
||||
|
|
@ -208,7 +269,7 @@ Payload shape:
|
|||
{
|
||||
"id": 9,
|
||||
"name": "Playoff token collection",
|
||||
"key": "group_stage_survivor_playoff_tokens",
|
||||
"key": "playoff_token_collection",
|
||||
"action_name": "Collect playoff token",
|
||||
"source": "group_stage_survivors",
|
||||
"team_action_items": [
|
||||
|
|
@ -239,20 +300,22 @@ Practical frontend pattern:
|
|||
1. load initial tournament state over HTTP
|
||||
2. open websocket subscription for same tournament id
|
||||
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
|
||||
5. let websocket keep other viewers in sync
|
||||
4. if owner submits create form, send one HTTP `POST` to create the list
|
||||
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.
|
||||
|
||||
## 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`
|
||||
- user-facing `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 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
|
||||
|
||||
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
|
||||
|
||||
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
|
||||
|
||||
def wait_for_payload!(timeout: 20)
|
||||
wait_for!(timeout: timeout) do |message|
|
||||
payload = message['message']
|
||||
payload.is_a?(Hash) && payload['type'] == 'team_action_lists.updated'
|
||||
team_action_lists_payload_message?(message)
|
||||
end.fetch('message')
|
||||
end
|
||||
|
||||
def wait_for_timer_payload!(timeout: 20)
|
||||
wait_for!(timeout: timeout) do |message|
|
||||
payload = message['message']
|
||||
payload.is_a?(Hash) && payload['type'] == 'timer.updated'
|
||||
timer_payload_message?(message)
|
||||
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
|
||||
|
||||
|
|
@ -60,23 +90,8 @@ module TurniereE2E
|
|||
|
||||
attr_reader :base_url, :socket, :driver
|
||||
|
||||
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
|
||||
|
||||
def subscribe!(channel:, tournament_id:)
|
||||
def subscribe!(identifier:)
|
||||
connect!
|
||||
identifier = {
|
||||
channel: channel,
|
||||
tournament_id: tournament_id
|
||||
}.to_json
|
||||
|
||||
driver.text(
|
||||
JSON.dump(
|
||||
|
|
@ -87,10 +102,21 @@ module TurniereE2E
|
|||
|
||||
handshake_message = wait_for! do |message|
|
||||
message['type'] == 'confirm_subscription' ||
|
||||
(message['identifier'] == identifier && message['message'].is_a?(Hash))
|
||||
(message['identifier'] == identifier && yield(message))
|
||||
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
|
||||
|
||||
def build_socket
|
||||
|
|
@ -154,9 +180,19 @@ module TurniereE2E
|
|||
nil
|
||||
end
|
||||
|
||||
def payload_message?(message)
|
||||
def team_action_lists_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
|
||||
|
|
|
|||
|
|
@ -92,14 +92,9 @@ RSpec.describe StagesController, type: :controller do
|
|||
.to match_array(GroupStageService.get_advancing_teams(running_group_stage))
|
||||
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
|
||||
list = tournament.team_action_lists.find_by!(key: TeamActionListService::GROUP_STAGE_SURVIVOR_PLAYOFF_TOKEN_KEY)
|
||||
|
||||
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])
|
||||
expect(tournament.team_action_lists).to be_empty
|
||||
end
|
||||
end
|
||||
|
||||
|
|
|
|||
|
|
@ -49,5 +49,19 @@ RSpec.describe TeamActionItemsController, type: :controller do
|
|||
expect(response).to have_http_status(:forbidden)
|
||||
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
|
||||
|
|
|
|||
|
|
@ -3,20 +3,113 @@
|
|||
require 'rails_helper'
|
||||
|
||||
RSpec.describe TeamActionListsController, type: :controller do
|
||||
let(:tournament) { create(:tournament, teams_count: 4) }
|
||||
let(:team_action_item) { create(:team_action_item) }
|
||||
let(:tournament) { team_action_item.tournament }
|
||||
let(:list) { team_action_item.team_action_list }
|
||||
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
|
||||
before do
|
||||
apply_authentication_headers_for tournament.owner
|
||||
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
|
||||
patch :update_item, params: {
|
||||
tournament_id: tournament.to_param,
|
||||
tournament_id: list.tournament.to_param,
|
||||
key: list.key,
|
||||
team_id: team.to_param,
|
||||
completed: true
|
||||
|
|
@ -29,7 +122,7 @@ RSpec.describe TeamActionListsController, type: :controller do
|
|||
it 'is idempotent when same state sent repeatedly' do
|
||||
2.times do
|
||||
patch :update_item, params: {
|
||||
tournament_id: tournament.to_param,
|
||||
tournament_id: list.tournament.to_param,
|
||||
key: list.key,
|
||||
team_id: team.to_param,
|
||||
completed: true
|
||||
|
|
@ -44,13 +137,13 @@ RSpec.describe TeamActionListsController, type: :controller do
|
|||
allow(TournamentSyncEnqueue).to receive(:call)
|
||||
|
||||
patch :update_item, params: {
|
||||
tournament_id: tournament.to_param,
|
||||
tournament_id: list.tournament.to_param,
|
||||
key: list.key,
|
||||
team_id: team.to_param,
|
||||
completed: true
|
||||
}
|
||||
|
||||
expect(TournamentSyncEnqueue).to have_received(:call).with(tournament)
|
||||
expect(TournamentSyncEnqueue).to have_received(:call).with(list.tournament)
|
||||
end
|
||||
end
|
||||
|
||||
|
|
@ -61,7 +154,7 @@ RSpec.describe TeamActionListsController, type: :controller do
|
|||
|
||||
it 'returns forbidden' do
|
||||
patch :update_item, params: {
|
||||
tournament_id: tournament.to_param,
|
||||
tournament_id: list.tournament.to_param,
|
||||
key: list.key,
|
||||
team_id: team.to_param,
|
||||
completed: true
|
||||
|
|
@ -70,5 +163,39 @@ RSpec.describe TeamActionListsController, type: :controller do
|
|||
expect(response).to have_http_status(:forbidden)
|
||||
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
|
||||
|
|
|
|||
|
|
@ -214,7 +214,7 @@ RSpec.describe TournamentsController, type: :controller do
|
|||
body = deserialize_response response
|
||||
|
||||
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)
|
||||
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(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(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(follower)).not_to be_nil
|
||||
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) })
|
||||
|
||||
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_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' })
|
||||
|
|
@ -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')
|
||||
source_token_item = playoff_token_list(source).fetch(:team_action_items).first
|
||||
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 }
|
||||
)
|
||||
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))
|
||||
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 }
|
||||
)
|
||||
expect(locked_token_update[:status]).to eq(423)
|
||||
|
|
@ -265,6 +283,17 @@ RSpec.describe 'Tournament follower sync HTTP E2E' do
|
|||
response.fetch(:json)
|
||||
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:)
|
||||
amount = rand(range)
|
||||
Array.new(amount) do |index|
|
||||
|
|
@ -552,10 +581,14 @@ RSpec.describe 'Tournament follower sync HTTP E2E' do
|
|||
|
||||
def playoff_token_list(tournament)
|
||||
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
|
||||
|
||||
def playoff_token_list_key
|
||||
'playoff_token_collection'
|
||||
end
|
||||
|
||||
def find_team_action_item(tournament, team_name:)
|
||||
playoff_token_list(tournament).fetch(:team_action_items).find do |item|
|
||||
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)
|
||||
|
||||
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))
|
||||
|
||||
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)
|
||||
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:)
|
||||
tournament = fetch_tournament(base_url: source_base_url, tournament_id: source_id)
|
||||
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)
|
||||
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
|
||||
|
||||
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.fetch('completed')
|
||||
end
|
||||
|
||||
def playoff_token_list_key
|
||||
'playoff_token_collection'
|
||||
end
|
||||
end
|
||||
|
|
|
|||
|
|
@ -63,6 +63,7 @@ RSpec.describe 'Tournament sync test HTTP E2E' do
|
|||
)
|
||||
|
||||
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)
|
||||
|
|
@ -73,7 +74,7 @@ RSpec.describe 'Tournament sync test HTTP E2E' do
|
|||
expect(update_by_item_id[:status]).to eq(200)
|
||||
|
||||
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 }
|
||||
)
|
||||
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)
|
||||
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
|
||||
api_client = TurniereE2E::ApiClient.new(base_url: base_url)
|
||||
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)
|
||||
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:)
|
||||
match_response = anonymous_client.get("/matches/#{match_id}")
|
||||
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)
|
||||
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
|
||||
|
||||
def playoff_token_list_key
|
||||
'playoff_token_collection'
|
||||
end
|
||||
|
||||
def playoff_token_item_completed?(tournament, team_name:)
|
||||
list = find_playoff_token_list!(tournament)
|
||||
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
|
||||
tournament = create_group_stage_tournament(name_prefix: 'Ws Leader')
|
||||
finish_group_stage_and_create_playoffs!(tournament.fetch(:id))
|
||||
create_team_action_list!(tournament.fetch(:id))
|
||||
tournament = fetch_tournament(tournament.fetch(:id))
|
||||
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))
|
||||
|
||||
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
|
||||
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)
|
||||
|
||||
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 }
|
||||
)
|
||||
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)
|
||||
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:)
|
||||
match_response = anonymous_client.get("/matches/#{match_id}")
|
||||
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)
|
||||
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
|
||||
|
||||
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.fetch('completed')
|
||||
end
|
||||
|
||||
def playoff_token_list_key
|
||||
'playoff_token_collection'
|
||||
end
|
||||
end
|
||||
|
|
|
|||
|
|
@ -4,8 +4,8 @@ FactoryBot.define do
|
|||
factory :team_action_list do
|
||||
tournament
|
||||
name { 'Playoff token collection' }
|
||||
key { 'group_stage_survivor_playoff_tokens' }
|
||||
key { 'playoff_token_collection' }
|
||||
action_name { 'Collect playoff token' }
|
||||
source { 'group_stage_survivors' }
|
||||
source { 'all_tournament_teams' }
|
||||
end
|
||||
end
|
||||
|
|
|
|||
|
|
@ -42,4 +42,54 @@ RSpec.describe Beamer, type: :model do
|
|||
expect(beamer.owner).to eq(beamer.tournament.owner)
|
||||
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
|
||||
|
|
|
|||
|
|
@ -4,12 +4,17 @@ require 'rails_helper'
|
|||
|
||||
RSpec.describe TeamActionListsController, type: :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
|
||||
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(
|
||||
'team_action_lists#update_item',
|
||||
tournament_id: '1',
|
||||
key: 'group_stage_survivor_playoff_tokens',
|
||||
key: 'playoff_token_collection',
|
||||
team_id: '2'
|
||||
)
|
||||
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"
|
||||
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_PROJECT = "turniere-blackbox"
|
||||
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"
|
||||
SPEC_ROOT = Path("spec")
|
||||
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
|
||||
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):
|
||||
|
|
@ -228,6 +232,31 @@ def _format_command(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):
|
||||
command = ["bundle", "exec", "rspec", *paths]
|
||||
if os.environ.get("CI"):
|
||||
|
|
@ -815,15 +844,15 @@ def scenario_render_profile(
|
|||
|
||||
|
||||
@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."""
|
||||
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")
|
||||
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."""
|
||||
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")
|
||||
|
|
@ -1697,6 +1726,7 @@ def blackbox_follow_service(
|
|||
"spec/e2e/http/tournament_follow_sync_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_follow_sync_beamers_websocket_spec.rb",
|
||||
],
|
||||
extra_env={
|
||||
"TURNIERE_SOURCE_E2E_BASE_URL": source_base_url,
|
||||
|
|
|
|||
Loading…
Reference in New Issue