diff --git a/.gitlab-ci.yml b/.gitlab-ci.yml index 81e7a6f..02802aa 100644 --- a/.gitlab-ci.yml +++ b/.gitlab-ci.yml @@ -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 diff --git a/AGENTS.md b/AGENTS.md index a26dc23..bd49d17 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -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 diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..9fb91e7 --- /dev/null +++ b/Dockerfile @@ -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"] diff --git a/README.md b/README.md index 670b9c6..049050c 100644 --- a/README.md +++ b/README.md @@ -31,6 +31,7 @@ Leader/follower tournament sync docs: - [doc/leader_follower.md](doc/leader_follower.md) - [doc/timer_api.md](doc/timer_api.md) +- [doc/beamer_live_updates.md](doc/beamer_live_updates.md) ## Docker [Registry](https://gitlab.com/turniere/turniere-backend/container_registry) diff --git a/app/channels/tournament_beamers_channel.rb b/app/channels/tournament_beamers_channel.rb new file mode 100644 index 0000000..cbcbf7e --- /dev/null +++ b/app/channels/tournament_beamers_channel.rb @@ -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 diff --git a/app/controllers/stages_controller.rb b/app/controllers/stages_controller.rb index 5cada55..fcdac4d 100644 --- a/app/controllers/stages_controller.rb +++ b/app/controllers/stages_controller.rb @@ -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 diff --git a/app/controllers/team_action_items_controller.rb b/app/controllers/team_action_items_controller.rb index b63a5d0..a63fd07 100644 --- a/app/controllers/team_action_items_controller.rb +++ b/app/controllers/team_action_items_controller.rb @@ -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 diff --git a/app/controllers/team_action_lists_controller.rb b/app/controllers/team_action_lists_controller.rb index bff4b94..9938fa9 100644 --- a/app/controllers/team_action_lists_controller.rb +++ b/app/controllers/team_action_lists_controller.rb @@ -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 diff --git a/app/models/beamer.rb b/app/models/beamer.rb index 180f915..47f44e0 100644 --- a/app/models/beamer.rb +++ b/app/models/beamer.rb @@ -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 diff --git a/app/models/team_action_list.rb b/app/models/team_action_list.rb index 1cbedcf..018bd0a 100644 --- a/app/models/team_action_list.rb +++ b/app/models/team_action_list.rb @@ -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 diff --git a/app/serializers/beamer_serializer.rb b/app/serializers/beamer_serializer.rb index ca15284..d6863f1 100644 --- a/app/serializers/beamer_serializer.rb +++ b/app/serializers/beamer_serializer.rb @@ -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 diff --git a/app/services/beamer_broadcast.rb b/app/services/beamer_broadcast.rb new file mode 100644 index 0000000..4fba0a2 --- /dev/null +++ b/app/services/beamer_broadcast.rb @@ -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 diff --git a/app/services/beamer_broadcast_payload.rb b/app/services/beamer_broadcast_payload.rb new file mode 100644 index 0000000..cc47b80 --- /dev/null +++ b/app/services/beamer_broadcast_payload.rb @@ -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 diff --git a/app/services/team_action_list_service.rb b/app/services/team_action_list_service.rb index e613ee3..11b66e0 100644 --- a/app/services/team_action_list_service.rb +++ b/app/services/team_action_list_service.rb @@ -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 diff --git a/config/routes.rb b/config/routes.rb index 75de6ab..9c53ca4 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -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] diff --git a/doc/beamer_live_updates.md b/doc/beamer_live_updates.md new file mode 100644 index 0000000..155349e --- /dev/null +++ b/doc/beamer_live_updates.md @@ -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 diff --git a/doc/buildx_migration_notes.md b/doc/buildx_migration_notes.md new file mode 100644 index 0000000..0358c44 --- /dev/null +++ b/doc/buildx_migration_notes.md @@ -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 diff --git a/doc/team_action_lists_api.md b/doc/team_action_lists_api.md index 9c1fd25..9adeeb9 100644 --- a/doc/team_action_lists_api.md +++ b/doc/team_action_lists_api.md @@ -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 diff --git a/docker/production/Dockerfile b/docker/production/Dockerfile deleted file mode 100644 index f4d11ff..0000000 --- a/docker/production/Dockerfile +++ /dev/null @@ -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 diff --git a/docker/test/Dockerfile b/docker/test/Dockerfile deleted file mode 100644 index a2446ba..0000000 --- a/docker/test/Dockerfile +++ /dev/null @@ -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 diff --git a/e2e/lib/action_cable_client.rb b/e2e/lib/action_cable_client.rb index a412813..0102ced 100644 --- a/e2e/lib/action_cable_client.rb +++ b/e2e/lib/action_cable_client.rb @@ -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 diff --git a/spec/controllers/stages_controller_spec.rb b/spec/controllers/stages_controller_spec.rb index 259427f..b9e7c2b 100644 --- a/spec/controllers/stages_controller_spec.rb +++ b/spec/controllers/stages_controller_spec.rb @@ -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 diff --git a/spec/controllers/team_action_items_controller_spec.rb b/spec/controllers/team_action_items_controller_spec.rb index efebd7a..569d6d5 100644 --- a/spec/controllers/team_action_items_controller_spec.rb +++ b/spec/controllers/team_action_items_controller_spec.rb @@ -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 diff --git a/spec/controllers/team_action_lists_controller_spec.rb b/spec/controllers/team_action_lists_controller_spec.rb index fdb9322..672d321 100644 --- a/spec/controllers/team_action_lists_controller_spec.rb +++ b/spec/controllers/team_action_lists_controller_spec.rb @@ -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 diff --git a/spec/controllers/tournaments_controller_spec.rb b/spec/controllers/tournaments_controller_spec.rb index f4c31cf..9ebbca2 100644 --- a/spec/controllers/tournaments_controller_spec.rb +++ b/spec/controllers/tournaments_controller_spec.rb @@ -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 diff --git a/spec/e2e/http/tournament_beamers_websocket_spec.rb b/spec/e2e/http/tournament_beamers_websocket_spec.rb new file mode 100644 index 0000000..4588172 --- /dev/null +++ b/spec/e2e/http/tournament_beamers_websocket_spec.rb @@ -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 diff --git a/spec/e2e/http/tournament_follow_sync_beamers_websocket_spec.rb b/spec/e2e/http/tournament_follow_sync_beamers_websocket_spec.rb new file mode 100644 index 0000000..19eedf8 --- /dev/null +++ b/spec/e2e/http/tournament_follow_sync_beamers_websocket_spec.rb @@ -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 diff --git a/spec/e2e/http/tournament_follow_sync_spec.rb b/spec/e2e/http/tournament_follow_sync_spec.rb index 90edc01..1800ad6 100644 --- a/spec/e2e/http/tournament_follow_sync_spec.rb +++ b/spec/e2e/http/tournament_follow_sync_spec.rb @@ -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 diff --git a/spec/e2e/http/tournament_follow_sync_websocket_spec.rb b/spec/e2e/http/tournament_follow_sync_websocket_spec.rb index 15b5e6b..f748a93 100644 --- a/spec/e2e/http/tournament_follow_sync_websocket_spec.rb +++ b/spec/e2e/http/tournament_follow_sync_websocket_spec.rb @@ -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 diff --git a/spec/e2e/http/tournament_sync_test_spec.rb b/spec/e2e/http/tournament_sync_test_spec.rb index 9d0b871..1d82f59 100644 --- a/spec/e2e/http/tournament_sync_test_spec.rb +++ b/spec/e2e/http/tournament_sync_test_spec.rb @@ -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 } diff --git a/spec/e2e/http/tournament_team_action_lists_websocket_spec.rb b/spec/e2e/http/tournament_team_action_lists_websocket_spec.rb index 4e21fec..d61149e 100644 --- a/spec/e2e/http/tournament_team_action_lists_websocket_spec.rb +++ b/spec/e2e/http/tournament_team_action_lists_websocket_spec.rb @@ -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 diff --git a/spec/factories/team_action_lists.rb b/spec/factories/team_action_lists.rb index 12e240c..a4d335b 100644 --- a/spec/factories/team_action_lists.rb +++ b/spec/factories/team_action_lists.rb @@ -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 diff --git a/spec/models/beamer_spec.rb b/spec/models/beamer_spec.rb index 34d77d1..ba9681b 100644 --- a/spec/models/beamer_spec.rb +++ b/spec/models/beamer_spec.rb @@ -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 diff --git a/spec/routing/team_action_lists_routing_spec.rb b/spec/routing/team_action_lists_routing_spec.rb index 121778e..55785b0 100644 --- a/spec/routing/team_action_lists_routing_spec.rb +++ b/spec/routing/team_action_lists_routing_spec.rb @@ -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 diff --git a/spec/services/team_action_list_service_spec.rb b/spec/services/team_action_list_service_spec.rb new file mode 100644 index 0000000..f71d4a1 --- /dev/null +++ b/spec/services/team_action_list_service_spec.rb @@ -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 diff --git a/tasks.py b/tasks.py index 59e5229..6c3fd0e 100644 --- a/tasks.py +++ b/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,